mirror of
				https://github.com/steveiliop56/tinyauth.git
				synced 2025-10-29 13:15:46 +00:00 
			
		
		
		
	Compare commits
	
		
			22 Commits
		
	
	
		
			v0.3.0-bet
			...
			v1.0.0-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 2c7a3fc801 | ||
|   | 61fffb9708 | ||
|   | 9d2aef163b | ||
|   | cc480085c5 | ||
|   | 2c7144937a | ||
|   | c7ec788ce1 | ||
|   | 96a373a794 | ||
|   | c5a8639822 | ||
|   | b87cb54d91 | ||
|   | f61b6dbad4 | ||
|   | 35854f5ce4 | ||
|   | c59aaa5600 | ||
|   | 085b1492cc | ||
|   | a19f3589f8 | ||
|   | e88ec22ce3 | ||
|   | 90f4c3c980 | ||
|   | f487e25ac5 | ||
|   | d4eca52b12 | ||
|   | 433e71bd50 | ||
|   | 80d25551e0 | ||
|   | 143b13af2c | ||
|   | 4457d6f525 | 
| @@ -35,10 +35,10 @@ COPY ./cmd ./cmd | |||||||
| COPY ./internal ./internal | COPY ./internal ./internal | ||||||
| COPY --from=site-builder /site/dist ./internal/assets/dist | COPY --from=site-builder /site/dist ./internal/assets/dist | ||||||
|  |  | ||||||
| RUN go build | RUN CGO_ENABLED=0 go build | ||||||
|  |  | ||||||
| # Runner | # Runner | ||||||
| FROM busybox:1.37-musl AS runner | FROM alpine:3.21 AS runner | ||||||
|  |  | ||||||
| WORKDIR /tinyauth | WORKDIR /tinyauth | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										50
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										50
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,36 +1,46 @@ | |||||||
| # Tinyauth - The simplest way to protect your apps with a login screen | <div align="center"> | ||||||
|  |     <img alt="Tinyauth" title="Tinyauth" width="256" src="site/public/logo.png"> | ||||||
|  |     <h1>Tinyauth</h1> | ||||||
|  |     <p>The easiest way to secure your apps with a login screen.</p> | ||||||
|  | </div> | ||||||
|  |  | ||||||
| Tinyauth is an extremely simple traefik middleware that adds a login screen to all of your apps that are using the traefik reverse proxy. Tinyauth is configurable through environment variables and it is only 20MB in size. | <div align="center"> | ||||||
|  |     <img alt="License" src="https://img.shields.io/github/license/steveiliop56/tinyauth"> | ||||||
|  |     <img alt="Release" src="https://img.shields.io/github/v/release/steveiliop56/tinyauth"> | ||||||
|  |     <img alt="Commit activity" src="https://img.shields.io/github/commit-activity/w/steveiliop56/tinyauth"> | ||||||
|  |     <img alt="Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/steveiliop56/tinyauth/release.yml"> | ||||||
|  |     <img alt="Issues" src="https://img.shields.io/github/issues/steveiliop56/tinyauth"> | ||||||
|  | </div> | ||||||
|  |  | ||||||
| ## Getting started | <br /> | ||||||
|  |  | ||||||
| Tinyauth is extremely easy to run since it's shipped as a docker container. The guide on how to get started is available on the website [here](https://tinyauth.doesmycode.work/). | Tinyauth is a simple authentication middleware that adds simple email/password login to all of your docker apps. It is made for traefik but it can be extended to work with all reverse proxies like caddy and nginx. | ||||||
|  |  | ||||||
| ## FAQ | > [!WARNING] | ||||||
|  | > Tinyauth is in active development and configuration may change often. Please make sure to carefully read the release notes before updating. | ||||||
|  |  | ||||||
| ### Why? | > [!NOTE] | ||||||
|  | > Tinyauth is intended for homelab use and it is not made for production use cases. If you are looking for something production ready please use [authentik](https://goauthentik.io). | ||||||
|  |  | ||||||
| Why make this project? Well, we all know that more powerful alternatives like authentik and authelia exist, but when I tried to use them, I felt overwhelmed with all the configration options and environment variables I had to configure in order for them to work. So, I decided to make a small alternative in Go to both test my skills and cover my simple login screen needs. | ## Getting Started | ||||||
|  |  | ||||||
| ### Is this secure? | You can easily get started with tinyauth by following the guide on the documentation [here](https://tinyauth.doesmycode.work/docs/getting-started.html). There is also an available docker compose file [here](./docker-compose.example.yml) that has traefik, nginx and tinyauth to demonstrate its capabilities. | ||||||
|  |  | ||||||
| Probably, the sessions are managed with the gin sessions package so it should be very secure. It is definitely not made for production but it could easily serve as a simple login screen to all of your homelab apps. | ## Documentation | ||||||
|  |  | ||||||
| ### Do I need to login every time? | You can find documentation and guides on all available configuration of tinyauth [here](https://tinyauth.doesmycode.work). | ||||||
|  |  | ||||||
| No, when you login, tinyauth sets a `tinyauth` cookie in your browser that applies to all of the subdomains of your domain. |  | ||||||
|  |  | ||||||
| ## License |  | ||||||
|  |  | ||||||
| Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions. |  | ||||||
|  |  | ||||||
| ## Contributing | ## Contributing | ||||||
|  |  | ||||||
| Any contributions to the codebase are welcome! I am not a cybersecurity person so my code may have a security issue, if you find something that could be used to exploit and bypass tinyauth please let me know as soon as possible so I can fix it. | All contributions to the codebase are welcome! If you have any recommendations on how to improve security or find a security issue in tinyauth please open an issue or pull request so it can be fixed as soon as possible! | ||||||
|  |  | ||||||
|  | ## License | ||||||
|  |  | ||||||
|  | Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions. For more information about the license check the [license](./LICENSE) file. | ||||||
|  |  | ||||||
| ## Acknowledgements | ## Acknowledgements | ||||||
|  |  | ||||||
| Credits for the logo go to: | Credits for the logo of this app go to: | ||||||
|  |  | ||||||
| - Freepik for providing the hat and police badge. | - **Freepik** for providing the police hat and logo. | ||||||
| - Renee French for making the gopher logo. | - **Renee French** for the original gopher logo. | ||||||
|   | |||||||
							
								
								
									
										81
									
								
								cmd/root.go
									
									
									
									
									
								
							
							
						
						
									
										81
									
								
								cmd/root.go
									
									
									
									
									
								
							| @@ -1,12 +1,11 @@ | |||||||
| package cmd | package cmd | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"os" |  | ||||||
| 	"strings" |  | ||||||
| 	cmd "tinyauth/cmd/user" | 	cmd "tinyauth/cmd/user" | ||||||
| 	"tinyauth/internal/api" | 	"tinyauth/internal/api" | ||||||
| 	"tinyauth/internal/auth" | 	"tinyauth/internal/auth" | ||||||
| 	"tinyauth/internal/hooks" | 	"tinyauth/internal/hooks" | ||||||
|  | 	"tinyauth/internal/providers" | ||||||
| 	"tinyauth/internal/types" | 	"tinyauth/internal/types" | ||||||
| 	"tinyauth/internal/utils" | 	"tinyauth/internal/utils" | ||||||
|  |  | ||||||
| @@ -19,7 +18,7 @@ import ( | |||||||
| var rootCmd = &cobra.Command{ | var rootCmd = &cobra.Command{ | ||||||
| 	Use:   "tinyauth", | 	Use:   "tinyauth", | ||||||
| 	Short: "An extremely simple traefik forward auth proxy.", | 	Short: "An extremely simple traefik forward auth proxy.", | ||||||
| 	Long: `Tinyauth is an extremely simple traefik forward-auth login screen that makes securing your apps easy.`, | 	Long:  `Tinyauth is an extremely simple traefik forward-auth login screen that makes securing your apps easy.`, | ||||||
| 	Run: func(cmd *cobra.Command, args []string) { | 	Run: func(cmd *cobra.Command, args []string) { | ||||||
| 		// Get config | 		// Get config | ||||||
| 		log.Info().Msg("Parsing config") | 		log.Info().Msg("Parsing config") | ||||||
| @@ -38,7 +37,6 @@ var rootCmd = &cobra.Command{ | |||||||
|  |  | ||||||
| 		if config.UsersFile == "" && config.Users == "" { | 		if config.UsersFile == "" && config.Users == "" { | ||||||
| 			log.Fatal().Msg("No users provided") | 			log.Fatal().Msg("No users provided") | ||||||
| 			os.Exit(1) |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		usersString := config.Users | 		usersString := config.Users | ||||||
| @@ -47,7 +45,7 @@ var rootCmd = &cobra.Command{ | |||||||
| 			log.Info().Msg("Reading users from file") | 			log.Info().Msg("Reading users from file") | ||||||
| 			usersFromFile, readErr := utils.GetUsersFromFile(config.UsersFile) | 			usersFromFile, readErr := utils.GetUsersFromFile(config.UsersFile) | ||||||
| 			HandleError(readErr, "Failed to read users from file") | 			HandleError(readErr, "Failed to read users from file") | ||||||
| 			usersFromFileParsed := strings.Join(strings.Split(usersFromFile, "\n"), ",") | 			usersFromFileParsed := utils.ParseFileToLine(usersFromFile) | ||||||
| 			if usersString != "" { | 			if usersString != "" { | ||||||
| 				usersString = usersString + "," + usersFromFileParsed | 				usersString = usersString + "," + usersFromFileParsed | ||||||
| 			} else { | 			} else { | ||||||
| @@ -58,20 +56,45 @@ var rootCmd = &cobra.Command{ | |||||||
| 		users, parseErr := utils.ParseUsers(usersString) | 		users, parseErr := utils.ParseUsers(usersString) | ||||||
| 		HandleError(parseErr, "Failed to parse users") | 		HandleError(parseErr, "Failed to parse users") | ||||||
|  |  | ||||||
|  | 		// Create oauth whitelist | ||||||
|  | 		oauthWhitelist := utils.ParseCommaString(config.OAuthWhitelist) | ||||||
|  |  | ||||||
|  | 		// Create OAuth config | ||||||
|  | 		oauthConfig := types.OAuthConfig{ | ||||||
|  | 			GithubClientId:      config.GithubClientId, | ||||||
|  | 			GithubClientSecret:  config.GithubClientSecret, | ||||||
|  | 			GoogleClientId:      config.GoogleClientId, | ||||||
|  | 			GoogleClientSecret:  config.GoogleClientSecret, | ||||||
|  | 			GenericClientId:     config.GenericClientId, | ||||||
|  | 			GenericClientSecret: config.GenericClientSecret, | ||||||
|  | 			GenericScopes:       utils.ParseCommaString(config.GenericScopes), | ||||||
|  | 			GenericAuthURL:      config.GenericAuthURL, | ||||||
|  | 			GenericTokenURL:     config.GenericTokenURL, | ||||||
|  | 			GenericUserURL:      config.GenericUserURL, | ||||||
|  | 			AppURL:              config.AppURL, | ||||||
|  | 		} | ||||||
|  |  | ||||||
| 		// Create auth service | 		// Create auth service | ||||||
| 		auth := auth.NewAuth(users) | 		auth := auth.NewAuth(users, oauthWhitelist) | ||||||
|  |  | ||||||
|  | 		// Create OAuth providers service | ||||||
|  | 		providers := providers.NewProviders(oauthConfig) | ||||||
|  |  | ||||||
|  | 		// Initialize providers | ||||||
|  | 		providers.Init() | ||||||
|  |  | ||||||
| 		// Create hooks service | 		// Create hooks service | ||||||
| 		hooks := hooks.NewHooks(auth) | 		hooks := hooks.NewHooks(auth, providers) | ||||||
|  |  | ||||||
| 		// Create API | 		// Create API | ||||||
| 		api := api.NewAPI(types.APIConfig{ | 		api := api.NewAPI(types.APIConfig{ | ||||||
| 			Port: config.Port, | 			Port:            config.Port, | ||||||
| 			Address: config.Address, | 			Address:         config.Address, | ||||||
| 			Secret: config.Secret, | 			Secret:          config.Secret, | ||||||
| 			AppURL: config.AppURL, | 			AppURL:          config.AppURL, | ||||||
| 			CookieSecure: config.CookieSecure, | 			CookieSecure:    config.CookieSecure, | ||||||
| 		}, hooks, auth) | 			DisableContinue: config.DisableContinue, | ||||||
|  | 		}, hooks, auth, providers) | ||||||
|  |  | ||||||
| 		// Setup routes | 		// Setup routes | ||||||
| 		api.Init() | 		api.Init() | ||||||
| @@ -86,31 +109,57 @@ func Execute() { | |||||||
| 	err := rootCmd.Execute() | 	err := rootCmd.Execute() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Fatal().Err(err).Msg("Failed to execute command") | 		log.Fatal().Err(err).Msg("Failed to execute command") | ||||||
| 		os.Exit(1) |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func HandleError(err error, msg string) { | func HandleError(err error, msg string) { | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Fatal().Err(err).Msg(msg) | 		log.Fatal().Err(err).Msg(msg) | ||||||
| 		os.Exit(1) |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func init() { | func init() { | ||||||
| 	rootCmd.AddCommand(cmd.UserCmd()) | 	rootCmd.AddCommand(cmd.UserCmd()) | ||||||
| 	viper.AutomaticEnv() | 	viper.AutomaticEnv() | ||||||
| 	rootCmd.Flags().IntP("port", "p", 3000, "Port to run the server on.") | 	rootCmd.Flags().Int("port", 3000, "Port to run the server on.") | ||||||
| 	rootCmd.Flags().String("address", "0.0.0.0", "Address to bind the server to.") | 	rootCmd.Flags().String("address", "0.0.0.0", "Address to bind the server to.") | ||||||
| 	rootCmd.Flags().String("secret", "", "Secret to use for the cookie.") | 	rootCmd.Flags().String("secret", "", "Secret to use for the cookie.") | ||||||
| 	rootCmd.Flags().String("app-url", "", "The tinyauth URL.") | 	rootCmd.Flags().String("app-url", "", "The tinyauth URL.") | ||||||
| 	rootCmd.Flags().String("users", "", "Comma separated list of users in the format username:bcrypt-hashed-password.") | 	rootCmd.Flags().String("users", "", "Comma separated list of users in the format username:bcrypt-hashed-password.") | ||||||
| 	rootCmd.Flags().String("users-file", "", "Path to a file containing users in the format username:bcrypt-hashed-password.") | 	rootCmd.Flags().String("users-file", "", "Path to a file containing users in the format username:bcrypt-hashed-password.") | ||||||
|  | 	rootCmd.Flags().Bool("cookie-secure", false, "Send cookie over secure connection only.") | ||||||
|  | 	rootCmd.Flags().String("github-client-id", "", "Github OAuth client ID.") | ||||||
|  | 	rootCmd.Flags().String("github-client-secret", "", "Github OAuth client secret.") | ||||||
|  | 	rootCmd.Flags().String("google-client-id", "", "Google OAuth client ID.") | ||||||
|  | 	rootCmd.Flags().String("google-client-secret", "", "Google OAuth client secret.") | ||||||
|  | 	rootCmd.Flags().String("generic-client-id", "", "Generic OAuth client ID.") | ||||||
|  | 	rootCmd.Flags().String("generic-client-secret", "", "Generic OAuth client secret.") | ||||||
|  | 	rootCmd.Flags().String("generic-scopes", "", "Generic OAuth scopes.") | ||||||
|  | 	rootCmd.Flags().String("generic-auth-url", "", "Generic OAuth auth URL.") | ||||||
|  | 	rootCmd.Flags().String("generic-token-url", "", "Generic OAuth token URL.") | ||||||
|  | 	rootCmd.Flags().String("generic-user-url", "", "Generic OAuth user info URL.") | ||||||
|  | 	rootCmd.Flags().Bool("disable-continue", false, "Disable continue screen and redirect to app directly.") | ||||||
|  | 	rootCmd.Flags().String("oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth.") | ||||||
|  | 	rootCmd.Flags().Int("cookie-expiry", 86400, "Cookie expiration time in seconds.") | ||||||
| 	viper.BindEnv("port", "PORT") | 	viper.BindEnv("port", "PORT") | ||||||
| 	viper.BindEnv("address", "ADDRESS") | 	viper.BindEnv("address", "ADDRESS") | ||||||
| 	viper.BindEnv("secret", "SECRET") | 	viper.BindEnv("secret", "SECRET") | ||||||
| 	viper.BindEnv("app-url", "APP_URL") | 	viper.BindEnv("app-url", "APP_URL") | ||||||
| 	viper.BindEnv("users", "USERS") | 	viper.BindEnv("users", "USERS") | ||||||
| 	viper.BindEnv("users-file", "USERS_FILE") | 	viper.BindEnv("users-file", "USERS_FILE") | ||||||
|  | 	viper.BindEnv("cookie-secure", "COOKIE_SECURE") | ||||||
|  | 	viper.BindEnv("github-client-id", "GITHUB_CLIENT_ID") | ||||||
|  | 	viper.BindEnv("github-client-secret", "GITHUB_CLIENT_SECRET") | ||||||
|  | 	viper.BindEnv("google-client-id", "GOOGLE_CLIENT_ID") | ||||||
|  | 	viper.BindEnv("google-client-secret", "GOOGLE_CLIENT_SECRET") | ||||||
|  | 	viper.BindEnv("generic-client-id", "GENERIC_CLIENT_ID") | ||||||
|  | 	viper.BindEnv("generic-client-secret", "GENERIC_CLIENT_SECRET") | ||||||
|  | 	viper.BindEnv("generic-scopes", "GENERIC_SCOPES") | ||||||
|  | 	viper.BindEnv("generic-auth-url", "GENERIC_AUTH_URL") | ||||||
|  | 	viper.BindEnv("generic-token-url", "GENERIC_TOKEN_URL") | ||||||
|  | 	viper.BindEnv("generic-user-url", "GENERIC_USER_URL") | ||||||
|  | 	viper.BindEnv("disable-continue", "DISABLE_CONTINUE") | ||||||
|  | 	viper.BindEnv("oauth-whitelist", "WHITELIST") | ||||||
|  | 	viper.BindEnv("cookie-expiry", "COOKIE_EXPIRY") | ||||||
| 	viper.BindPFlags(rootCmd.Flags()) | 	viper.BindPFlags(rootCmd.Flags()) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -3,7 +3,6 @@ package create | |||||||
| import ( | import ( | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"os" |  | ||||||
| 	"strings" | 	"strings" | ||||||
|  |  | ||||||
| 	"github.com/charmbracelet/huh" | 	"github.com/charmbracelet/huh" | ||||||
| @@ -18,9 +17,9 @@ var password string | |||||||
| var docker bool | var docker bool | ||||||
|  |  | ||||||
| var CreateCmd = &cobra.Command{ | var CreateCmd = &cobra.Command{ | ||||||
| 	Use:  "create", | 	Use:   "create", | ||||||
| 	Short: "Create a user", | 	Short: "Create a user", | ||||||
| 	Long: `Create a user either interactively or by passing flags.`, | 	Long:  `Create a user either interactively or by passing flags.`, | ||||||
| 	Run: func(cmd *cobra.Command, args []string) { | 	Run: func(cmd *cobra.Command, args []string) { | ||||||
| 		if interactive { | 		if interactive { | ||||||
| 			form := huh.NewForm( | 			form := huh.NewForm( | ||||||
| @@ -47,13 +46,11 @@ var CreateCmd = &cobra.Command{ | |||||||
|  |  | ||||||
| 			if formErr != nil { | 			if formErr != nil { | ||||||
| 				log.Fatal().Err(formErr).Msg("Form failed") | 				log.Fatal().Err(formErr).Msg("Form failed") | ||||||
| 				os.Exit(1) |  | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if username == "" || password == "" { | 		if username == "" || password == "" { | ||||||
| 			log.Error().Msg("Username and password cannot be empty") | 			log.Error().Msg("Username and password cannot be empty") | ||||||
| 			os.Exit(1) |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		log.Info().Str("username", username).Str("password", password).Bool("docker", docker).Msg("Creating user") | 		log.Info().Str("username", username).Str("password", password).Bool("docker", docker).Msg("Creating user") | ||||||
| @@ -62,7 +59,6 @@ var CreateCmd = &cobra.Command{ | |||||||
|  |  | ||||||
| 		if passwordErr != nil { | 		if passwordErr != nil { | ||||||
| 			log.Fatal().Err(passwordErr).Msg("Failed to hash password") | 			log.Fatal().Err(passwordErr).Msg("Failed to hash password") | ||||||
| 			os.Exit(1) |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		passwordString := string(passwordByte) | 		passwordString := string(passwordByte) | ||||||
| @@ -76,8 +72,8 @@ var CreateCmd = &cobra.Command{ | |||||||
| } | } | ||||||
|  |  | ||||||
| func init() { | func init() { | ||||||
| 	CreateCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively") | 	CreateCmd.Flags().BoolVar(&interactive, "interactive", false, "Create a user interactively") | ||||||
| 	CreateCmd.Flags().BoolVarP(&docker, "docker", "d", false, "Format output for docker") | 	CreateCmd.Flags().BoolVar(&docker, "docker", false, "Format output for docker") | ||||||
| 	CreateCmd.Flags().StringVarP(&username, "username", "u", "", "Username") | 	CreateCmd.Flags().StringVar(&username, "username", "", "Username") | ||||||
| 	CreateCmd.Flags().StringVarP(&password, "password", "p", "", "Password") | 	CreateCmd.Flags().StringVar(&password, "password", "", "Password") | ||||||
| } | } | ||||||
| @@ -2,7 +2,6 @@ package verify | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"os" |  | ||||||
| 	"strings" | 	"strings" | ||||||
|  |  | ||||||
| 	"github.com/charmbracelet/huh" | 	"github.com/charmbracelet/huh" | ||||||
| @@ -18,9 +17,9 @@ var docker bool | |||||||
| var user string | var user string | ||||||
|  |  | ||||||
| var VerifyCmd = &cobra.Command{ | var VerifyCmd = &cobra.Command{ | ||||||
| 	Use: "verify", | 	Use:   "verify", | ||||||
| 	Short: "Verify a user is set up correctly", | 	Short: "Verify a user is set up correctly", | ||||||
| 	Long: `Verify a user is set up correctly meaning that it has a correct password.`, | 	Long:  `Verify a user is set up correctly meaning that it has a correct password.`, | ||||||
| 	Run: func(cmd *cobra.Command, args []string) { | 	Run: func(cmd *cobra.Command, args []string) { | ||||||
| 		if interactive { | 		if interactive { | ||||||
| 			form := huh.NewForm( | 			form := huh.NewForm( | ||||||
| @@ -53,23 +52,19 @@ var VerifyCmd = &cobra.Command{ | |||||||
|  |  | ||||||
| 			if formErr != nil { | 			if formErr != nil { | ||||||
| 				log.Fatal().Err(formErr).Msg("Form failed") | 				log.Fatal().Err(formErr).Msg("Form failed") | ||||||
| 				os.Exit(1) |  | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if username == "" || password == "" || user == "" { | 		if username == "" || password == "" || user == "" { | ||||||
| 			log.Error().Msg("Username, password and user cannot be empty") | 			log.Fatal().Msg("Username, password and user cannot be empty") | ||||||
| 			os.Exit(1) |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  |  | ||||||
| 		log.Info().Str("user", user).Str("username", username).Str("password", password).Bool("docker", docker).Msg("Verifying user") | 		log.Info().Str("user", user).Str("username", username).Str("password", password).Bool("docker", docker).Msg("Verifying user") | ||||||
|  |  | ||||||
| 		userSplit := strings.Split(user, ":") | 		userSplit := strings.Split(user, ":") | ||||||
|  |  | ||||||
| 		if userSplit[1] == "" { | 		if userSplit[1] == "" { | ||||||
| 			log.Error().Msg("User is not formatted correctly") | 			log.Fatal().Msg("User is not formatted correctly") | ||||||
| 			os.Exit(1) |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if docker { | 		if docker { | ||||||
| @@ -79,8 +74,7 @@ var VerifyCmd = &cobra.Command{ | |||||||
| 		verifyErr := bcrypt.CompareHashAndPassword([]byte(userSplit[1]), []byte(password)) | 		verifyErr := bcrypt.CompareHashAndPassword([]byte(userSplit[1]), []byte(password)) | ||||||
|  |  | ||||||
| 		if verifyErr != nil || username != userSplit[0] { | 		if verifyErr != nil || username != userSplit[0] { | ||||||
| 			log.Error().Msg("Username or password incorrect") | 			log.Fatal().Msg("Username or password incorrect") | ||||||
| 			os.Exit(1) |  | ||||||
| 		} else { | 		} else { | ||||||
| 			log.Info().Msg("Verification successful") | 			log.Info().Msg("Verification successful") | ||||||
| 		} | 		} | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								go.mod
									
									
									
									
									
								
							| @@ -72,6 +72,7 @@ require ( | |||||||
| 	golang.org/x/arch v0.13.0 // indirect | 	golang.org/x/arch v0.13.0 // indirect | ||||||
| 	golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect | 	golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect | ||||||
| 	golang.org/x/net v0.34.0 // indirect | 	golang.org/x/net v0.34.0 // indirect | ||||||
|  | 	golang.org/x/oauth2 v0.25.0 // indirect | ||||||
| 	golang.org/x/sync v0.10.0 // indirect | 	golang.org/x/sync v0.10.0 // indirect | ||||||
| 	golang.org/x/sys v0.29.0 // indirect | 	golang.org/x/sys v0.29.0 // indirect | ||||||
| 	golang.org/x/text v0.21.0 // indirect | 	golang.org/x/text v0.21.0 // indirect | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								go.sum
									
									
									
									
									
								
							| @@ -180,6 +180,8 @@ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjs | |||||||
| golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= | ||||||
| golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= | ||||||
| golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= | ||||||
|  | golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= | ||||||
|  | golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= | ||||||
| golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= | ||||||
| golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= | ||||||
| golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ import ( | |||||||
| 	"tinyauth/internal/assets" | 	"tinyauth/internal/assets" | ||||||
| 	"tinyauth/internal/auth" | 	"tinyauth/internal/auth" | ||||||
| 	"tinyauth/internal/hooks" | 	"tinyauth/internal/hooks" | ||||||
|  | 	"tinyauth/internal/providers" | ||||||
| 	"tinyauth/internal/types" | 	"tinyauth/internal/types" | ||||||
| 	"tinyauth/internal/utils" | 	"tinyauth/internal/utils" | ||||||
|  |  | ||||||
| @@ -20,20 +21,22 @@ import ( | |||||||
| 	"github.com/rs/zerolog/log" | 	"github.com/rs/zerolog/log" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| func NewAPI(config types.APIConfig, hooks *hooks.Hooks, auth *auth.Auth) (*API) { | func NewAPI(config types.APIConfig, hooks *hooks.Hooks, auth *auth.Auth, providers *providers.Providers) *API { | ||||||
| 	return &API{ | 	return &API{ | ||||||
| 		Config: config, | 		Config:    config, | ||||||
| 		Hooks: hooks, | 		Hooks:     hooks, | ||||||
| 		Auth: auth, | 		Auth:      auth, | ||||||
| 		Router: nil, | 		Providers: providers, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| type API struct { | type API struct { | ||||||
| 	Config types.APIConfig | 	Config    types.APIConfig | ||||||
| 	Router *gin.Engine | 	Router    *gin.Engine | ||||||
| 	Hooks *hooks.Hooks | 	Hooks     *hooks.Hooks | ||||||
| 	Auth *auth.Auth | 	Auth      *auth.Auth | ||||||
|  | 	Providers *providers.Providers | ||||||
|  | 	Domain    string | ||||||
| } | } | ||||||
|  |  | ||||||
| func (api *API) Init() { | func (api *API) Init() { | ||||||
| @@ -45,7 +48,6 @@ func (api *API) Init() { | |||||||
|  |  | ||||||
| 	if distErr != nil { | 	if distErr != nil { | ||||||
| 		log.Fatal().Err(distErr).Msg("Failed to get UI assets") | 		log.Fatal().Err(distErr).Msg("Failed to get UI assets") | ||||||
| 		os.Exit(1) |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	fileServer := http.FileServer(http.FS(dist)) | 	fileServer := http.FileServer(http.FS(dist)) | ||||||
| @@ -53,31 +55,26 @@ func (api *API) Init() { | |||||||
|  |  | ||||||
| 	domain, domainErr := utils.GetRootURL(api.Config.AppURL) | 	domain, domainErr := utils.GetRootURL(api.Config.AppURL) | ||||||
|  |  | ||||||
| 	log.Info().Str("domain", domain).Msg("Using domain for cookies") |  | ||||||
|  |  | ||||||
| 	if domainErr != nil { | 	if domainErr != nil { | ||||||
| 		log.Fatal().Err(domainErr).Msg("Failed to get domain") | 		log.Fatal().Err(domainErr).Msg("Failed to get domain") | ||||||
| 		os.Exit(1) | 		os.Exit(1) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	var isSecure bool | 	log.Info().Str("domain", domain).Msg("Using domain for cookies") | ||||||
|  |  | ||||||
| 	if api.Config.CookieSecure { | 	api.Domain = fmt.Sprintf(".%s", domain) | ||||||
| 		isSecure = true |  | ||||||
| 	} else { |  | ||||||
| 		isSecure = false |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	store.Options(sessions.Options{ | 	store.Options(sessions.Options{ | ||||||
| 		Domain: fmt.Sprintf(".%s", domain), | 		Domain:   api.Domain, | ||||||
| 		Path: "/", | 		Path:     "/", | ||||||
| 		HttpOnly: true, | 		HttpOnly: true, | ||||||
| 		Secure: isSecure, | 		Secure:   api.Config.CookieSecure, | ||||||
|  | 		MaxAge:   api.Config.CookieExpiry, | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
|   	router.Use(sessions.Sessions("tinyauth", store)) | 	router.Use(sessions.Sessions("tinyauth", store)) | ||||||
|  |  | ||||||
| 	  router.Use(func(c *gin.Context) { | 	router.Use(func(c *gin.Context) { | ||||||
| 		if !strings.HasPrefix(c.Request.URL.Path, "/api") { | 		if !strings.HasPrefix(c.Request.URL.Path, "/api") { | ||||||
| 			_, err := fs.Stat(dist, strings.TrimPrefix(c.Request.URL.Path, "/")) | 			_, err := fs.Stat(dist, strings.TrimPrefix(c.Request.URL.Path, "/")) | ||||||
| 			if os.IsNotExist(err) { | 			if os.IsNotExist(err) { | ||||||
| @@ -92,12 +89,21 @@ func (api *API) Init() { | |||||||
| } | } | ||||||
|  |  | ||||||
| func (api *API) SetupRoutes() { | func (api *API) SetupRoutes() { | ||||||
| 	api.Router.GET("/api/auth", func (c *gin.Context) { | 	api.Router.GET("/api/auth", func(c *gin.Context) { | ||||||
| 		userContext := api.Hooks.UseUserContext(c) | 		userContext, userContextErr := api.Hooks.UseUserContext(c) | ||||||
|  |  | ||||||
|  | 		if userContextErr != nil { | ||||||
|  | 			log.Error().Err(userContextErr).Msg("Failed to get user context") | ||||||
|  | 			c.JSON(500, gin.H{ | ||||||
|  | 				"status":  500, | ||||||
|  | 				"message": "Internal Server Error", | ||||||
|  | 			}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
| 		if userContext.IsLoggedIn { | 		if userContext.IsLoggedIn { | ||||||
| 			c.JSON(200, gin.H{ | 			c.JSON(200, gin.H{ | ||||||
| 				"status": 200, | 				"status":  200, | ||||||
| 				"message": "Authenticated", | 				"message": "Authenticated", | ||||||
| 			}) | 			}) | ||||||
| 			return | 			return | ||||||
| @@ -111,8 +117,9 @@ func (api *API) SetupRoutes() { | |||||||
| 		}) | 		}) | ||||||
|  |  | ||||||
| 		if queryErr != nil { | 		if queryErr != nil { | ||||||
|  | 			log.Error().Err(queryErr).Msg("Failed to build query") | ||||||
| 			c.JSON(501, gin.H{ | 			c.JSON(501, gin.H{ | ||||||
| 				"status": 501, | 				"status":  501, | ||||||
| 				"message": "Internal Server Error", | 				"message": "Internal Server Error", | ||||||
| 			}) | 			}) | ||||||
| 			return | 			return | ||||||
| @@ -121,24 +128,25 @@ func (api *API) SetupRoutes() { | |||||||
| 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/?%s", api.Config.AppURL, queries.Encode())) | 		c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/?%s", api.Config.AppURL, queries.Encode())) | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
| 	api.Router.POST("/api/login", func (c *gin.Context) { | 	api.Router.POST("/api/login", func(c *gin.Context) { | ||||||
| 		var login types.LoginRequest | 		var login types.LoginRequest | ||||||
|  |  | ||||||
| 		err := c.BindJSON(&login) | 		err := c.BindJSON(&login) | ||||||
|  |  | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
|  | 			log.Error().Err(err).Msg("Failed to bind JSON") | ||||||
| 			c.JSON(400, gin.H{ | 			c.JSON(400, gin.H{ | ||||||
| 				"status": 400, | 				"status":  400, | ||||||
| 				"message": "Bad Request", | 				"message": "Bad Request", | ||||||
| 			}) | 			}) | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		user := api.Auth.GetUser(login.Username) | 		user := api.Auth.GetUser(login.Email) | ||||||
|  |  | ||||||
| 		if user == nil { | 		if user == nil { | ||||||
| 			c.JSON(401, gin.H{ | 			c.JSON(401, gin.H{ | ||||||
| 				"status": 401, | 				"status":  401, | ||||||
| 				"message": "Unauthorized", | 				"message": "Unauthorized", | ||||||
| 			}) | 			}) | ||||||
| 			return | 			return | ||||||
| @@ -146,62 +154,192 @@ func (api *API) SetupRoutes() { | |||||||
|  |  | ||||||
| 		if !api.Auth.CheckPassword(*user, login.Password) { | 		if !api.Auth.CheckPassword(*user, login.Password) { | ||||||
| 			c.JSON(401, gin.H{ | 			c.JSON(401, gin.H{ | ||||||
| 				"status": 401, | 				"status":  401, | ||||||
| 				"message": "Unauthorized", | 				"message": "Unauthorized", | ||||||
| 			}) | 			}) | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		session := sessions.Default(c) | 		session := sessions.Default(c) | ||||||
| 		session.Set("tinyauth", user.Username) | 		session.Set("tinyauth_sid", fmt.Sprintf("email:%s", login.Email)) | ||||||
| 		session.Save() | 		session.Save() | ||||||
|  |  | ||||||
| 		c.JSON(200, gin.H{ | 		c.JSON(200, gin.H{ | ||||||
| 			"status": 200, | 			"status":  200, | ||||||
| 			"message": "Logged in", | 			"message": "Logged in", | ||||||
| 		}) | 		}) | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
| 	api.Router.POST("/api/logout", func (c *gin.Context) { | 	api.Router.POST("/api/logout", func(c *gin.Context) { | ||||||
| 		session := sessions.Default(c) | 		session := sessions.Default(c) | ||||||
| 		session.Delete("tinyauth") | 		session.Delete("tinyauth_sid") | ||||||
| 		session.Save() | 		session.Save() | ||||||
|  |  | ||||||
|  | 		c.SetCookie("tinyauth_redirect_uri", "", -1, "/", api.Domain, api.Config.CookieSecure, true) | ||||||
|  |  | ||||||
| 		c.JSON(200, gin.H{ | 		c.JSON(200, gin.H{ | ||||||
| 			"status": 200, | 			"status":  200, | ||||||
| 			"message": "Logged out", | 			"message": "Logged out", | ||||||
| 		}) | 		}) | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
| 	api.Router.GET("/api/status", func (c *gin.Context) { | 	api.Router.GET("/api/status", func(c *gin.Context) { | ||||||
| 		userContext := api.Hooks.UseUserContext(c) | 		userContext, userContextErr := api.Hooks.UseUserContext(c) | ||||||
|  |  | ||||||
|  | 		if userContextErr != nil { | ||||||
|  | 			log.Error().Err(userContextErr).Msg("Failed to get user context") | ||||||
|  | 			c.JSON(500, gin.H{ | ||||||
|  | 				"status":  500, | ||||||
|  | 				"message": "Internal Server Error", | ||||||
|  | 			}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
| 		if !userContext.IsLoggedIn { | 		if !userContext.IsLoggedIn { | ||||||
| 			c.JSON(200, gin.H{ | 			c.JSON(200, gin.H{ | ||||||
| 				"status": 200, | 				"status":              200, | ||||||
| 				"message": "Unauthenticated", | 				"message":             "Unauthenticated", | ||||||
| 				"username": "", | 				"email":               "", | ||||||
| 				"isLoggedIn": false, | 				"isLoggedIn":          false, | ||||||
|  | 				"oauth":               false, | ||||||
|  | 				"provider":            "", | ||||||
|  | 				"configuredProviders": api.Providers.GetConfiguredProviders(), | ||||||
|  | 				"disableContinue":     api.Config.DisableContinue, | ||||||
| 			}) | 			}) | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.JSON(200, gin.H{ | 		c.JSON(200, gin.H{ | ||||||
| 			"status": 200, | 			"status":              200, | ||||||
| 			"message": "Authenticated", | 			"message":             "Authenticated", | ||||||
| 			"username": userContext.Username, | 			"email":               userContext.Email, | ||||||
| 			"isLoggedIn": true, | 			"isLoggedIn":          userContext.IsLoggedIn, | ||||||
|  | 			"oauth":               userContext.OAuth, | ||||||
|  | 			"provider":            userContext.Provider, | ||||||
|  | 			"configuredProviders": api.Providers.GetConfiguredProviders(), | ||||||
|  | 			"disableContinue":     api.Config.DisableContinue, | ||||||
| 		}) | 		}) | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
| 	api.Router.GET("/api/healthcheck", func (c *gin.Context) { | 	api.Router.GET("/api/healthcheck", func(c *gin.Context) { | ||||||
| 		c.JSON(200, gin.H{ | 		c.JSON(200, gin.H{ | ||||||
| 			"status": 200, | 			"status":  200, | ||||||
| 			"message": "OK", | 			"message": "OK", | ||||||
| 		}) | 		}) | ||||||
| 	}) | 	}) | ||||||
| } |  | ||||||
|  |  | ||||||
|  | 	api.Router.GET("/api/oauth/url/:provider", func(c *gin.Context) { | ||||||
|  | 		var request types.OAuthRequest | ||||||
|  |  | ||||||
|  | 		bindErr := c.BindUri(&request) | ||||||
|  |  | ||||||
|  | 		if bindErr != nil { | ||||||
|  | 			log.Error().Err(bindErr).Msg("Failed to bind URI") | ||||||
|  | 			c.JSON(400, gin.H{ | ||||||
|  | 				"status":  400, | ||||||
|  | 				"message": "Bad Request", | ||||||
|  | 			}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		provider := api.Providers.GetProvider(request.Provider) | ||||||
|  |  | ||||||
|  | 		if provider == nil { | ||||||
|  | 			c.JSON(404, gin.H{ | ||||||
|  | 				"status":  404, | ||||||
|  | 				"message": "Not Found", | ||||||
|  | 			}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		authURL := provider.GetAuthURL() | ||||||
|  |  | ||||||
|  | 		redirectURI := c.Query("redirect_uri") | ||||||
|  |  | ||||||
|  | 		if redirectURI != "" { | ||||||
|  | 			c.SetCookie("tinyauth_redirect_uri", redirectURI, 3600, "/", api.Domain, api.Config.CookieSecure, true) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		c.JSON(200, gin.H{ | ||||||
|  | 			"status":  200, | ||||||
|  | 			"message": "Ok", | ||||||
|  | 			"url":     authURL, | ||||||
|  | 		}) | ||||||
|  | 	}) | ||||||
|  |  | ||||||
|  | 	api.Router.GET("/api/oauth/callback/:provider", func(c *gin.Context) { | ||||||
|  | 		var providerName types.OAuthRequest | ||||||
|  |  | ||||||
|  | 		bindErr := c.BindUri(&providerName) | ||||||
|  |  | ||||||
|  | 		if handleApiError(c, "Failed to bind URI", bindErr) { | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		code := c.Query("code") | ||||||
|  |  | ||||||
|  | 		if code == "" { | ||||||
|  | 			log.Error().Msg("No code provided") | ||||||
|  | 			c.Redirect(http.StatusPermanentRedirect, "/error") | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		provider := api.Providers.GetProvider(providerName.Provider) | ||||||
|  |  | ||||||
|  | 		if provider == nil { | ||||||
|  | 			c.Redirect(http.StatusPermanentRedirect, "/not-found") | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		token, tokenErr := provider.ExchangeToken(code) | ||||||
|  |  | ||||||
|  | 		if handleApiError(c, "Failed to exchange token", tokenErr) { | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		email, emailErr := api.Providers.GetUser(providerName.Provider) | ||||||
|  |  | ||||||
|  | 		if handleApiError(c, "Failed to get user", emailErr) { | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if !api.Auth.EmailWhitelisted(email) { | ||||||
|  | 			log.Warn().Str("email", email).Msg("Email not whitelisted") | ||||||
|  | 			unauthorizedQuery, unauthorizedQueryErr := query.Values(types.UnauthorizedQuery{ | ||||||
|  | 				Email: email, | ||||||
|  | 			}) | ||||||
|  | 			if handleApiError(c, "Failed to build query", unauthorizedQueryErr) { | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  | 			c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/unauthorized?%s", api.Config.AppURL, unauthorizedQuery.Encode())) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		session := sessions.Default(c) | ||||||
|  | 		session.Set("tinyauth_sid", fmt.Sprintf("%s:%s", providerName.Provider, token)) | ||||||
|  | 		session.Save() | ||||||
|  |  | ||||||
|  | 		redirectURI, redirectURIErr := c.Cookie("tinyauth_redirect_uri") | ||||||
|  |  | ||||||
|  | 		if redirectURIErr != nil { | ||||||
|  | 			c.JSON(200, gin.H{ | ||||||
|  | 				"status":  200, | ||||||
|  | 				"message": "Logged in", | ||||||
|  | 			}) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		c.SetCookie("tinyauth_redirect_uri", "", -1, "/", api.Domain, api.Config.CookieSecure, true) | ||||||
|  |  | ||||||
|  | 		redirectQuery, redirectQueryErr := query.Values(types.LoginQuery{ | ||||||
|  | 			RedirectURI: redirectURI, | ||||||
|  | 		}) | ||||||
|  |  | ||||||
|  | 		if handleApiError(c, "Failed to build query", redirectQueryErr) { | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/continue?%s", api.Config.AppURL, redirectQuery.Encode())) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
| func (api *API) Run() { | func (api *API) Run() { | ||||||
| 	log.Info().Str("address", api.Config.Address).Int("port", api.Config.Port).Msg("Starting server") | 	log.Info().Str("address", api.Config.Address).Int("port", api.Config.Port).Msg("Starting server") | ||||||
| @@ -222,12 +360,21 @@ func zerolog() gin.HandlerFunc { | |||||||
| 		latency := time.Since(tStart).String() | 		latency := time.Since(tStart).String() | ||||||
|  |  | ||||||
| 		switch { | 		switch { | ||||||
| 			case code >= 200 && code < 300: | 		case code >= 200 && code < 300: | ||||||
| 				log.Info().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request") | 			log.Info().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request") | ||||||
| 			case code >= 300 && code < 400: | 		case code >= 300 && code < 400: | ||||||
| 				log.Warn().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request") | 			log.Warn().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request") | ||||||
| 			case code >= 400: | 		case code >= 400: | ||||||
| 				log.Error().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request") | 			log.Error().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request") | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func handleApiError(c *gin.Context, msg string, err error) bool { | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Error().Err(err).Msg(msg) | ||||||
|  | 		c.Redirect(http.StatusPermanentRedirect, "/error") | ||||||
|  | 		return true | ||||||
|  | 	} | ||||||
|  | 	return false | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1 +1 @@ | |||||||
| v0.3.0 | v1.0.0 | ||||||
| @@ -6,19 +6,21 @@ import ( | |||||||
| 	"golang.org/x/crypto/bcrypt" | 	"golang.org/x/crypto/bcrypt" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| func NewAuth(userList types.Users) *Auth { | func NewAuth(userList types.Users, oauthWhitelist []string) *Auth { | ||||||
| 	return &Auth{ | 	return &Auth{ | ||||||
| 		Users: userList, | 		Users:          userList, | ||||||
|  | 		OAuthWhitelist: oauthWhitelist, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| type Auth struct { | type Auth struct { | ||||||
| 	Users types.Users | 	Users          types.Users | ||||||
|  | 	OAuthWhitelist []string | ||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *Auth) GetUser(username string) *types.User { | func (auth *Auth) GetUser(email string) *types.User { | ||||||
| 	for _, user := range auth.Users { | 	for _, user := range auth.Users { | ||||||
| 		if user.Username == username { | 		if user.Email == email { | ||||||
| 			return &user | 			return &user | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -29,3 +31,15 @@ func (auth *Auth) CheckPassword(user types.User, password string) bool { | |||||||
| 	hashedPasswordErr := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) | 	hashedPasswordErr := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) | ||||||
| 	return hashedPasswordErr == nil | 	return hashedPasswordErr == nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func (auth *Auth) EmailWhitelisted(emailSrc string) bool { | ||||||
|  | 	if len(auth.OAuthWhitelist) == 0 { | ||||||
|  | 		return true | ||||||
|  | 	} | ||||||
|  | 	for _, email := range auth.OAuthWhitelist { | ||||||
|  | 		if email == emailSrc { | ||||||
|  | 			return true | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return false | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,54 +1,125 @@ | |||||||
| package hooks | package hooks | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
|  | 	"strings" | ||||||
| 	"tinyauth/internal/auth" | 	"tinyauth/internal/auth" | ||||||
|  | 	"tinyauth/internal/providers" | ||||||
| 	"tinyauth/internal/types" | 	"tinyauth/internal/types" | ||||||
|  |  | ||||||
| 	"github.com/gin-contrib/sessions" | 	"github.com/gin-contrib/sessions" | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/gin-gonic/gin" | ||||||
|  | 	"golang.org/x/oauth2" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| func NewHooks(auth *auth.Auth) *Hooks { | func NewHooks(auth *auth.Auth, providers *providers.Providers) *Hooks { | ||||||
| 	return &Hooks{ | 	return &Hooks{ | ||||||
| 		Auth: auth, | 		Auth:      auth, | ||||||
|  | 		Providers: providers, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| type Hooks struct { | type Hooks struct { | ||||||
| 	Auth *auth.Auth | 	Auth      *auth.Auth | ||||||
|  | 	Providers *providers.Providers | ||||||
| } | } | ||||||
|  |  | ||||||
| func (hooks *Hooks) UseUserContext(c *gin.Context) (types.UserContext) { | func (hooks *Hooks) UseUserContext(c *gin.Context) (types.UserContext, error) { | ||||||
| 	session := sessions.Default(c) | 	session := sessions.Default(c) | ||||||
| 	cookie := session.Get("tinyauth") | 	sessionCookie := session.Get("tinyauth_sid") | ||||||
|  |  | ||||||
| 	if cookie == nil { | 	if sessionCookie == nil { | ||||||
| 		return types.UserContext{ | 		return types.UserContext{ | ||||||
| 			Username: "", | 			Email:      "", | ||||||
| 			IsLoggedIn: false, | 			IsLoggedIn: false, | ||||||
| 		} | 			OAuth:      false, | ||||||
|  | 			Provider:   "", | ||||||
|  | 		}, nil | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	username, ok := cookie.(string) | 	data, dataOk := sessionCookie.(string) | ||||||
|  |  | ||||||
| 	if !ok { | 	if !dataOk { | ||||||
| 		return types.UserContext{ | 		return types.UserContext{ | ||||||
| 			Username: "", | 			Email:      "", | ||||||
| 			IsLoggedIn: false, | 			IsLoggedIn: false, | ||||||
| 		} | 			OAuth:      false, | ||||||
|  | 			Provider:   "", | ||||||
|  | 		}, nil | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	user := hooks.Auth.GetUser(username) | 	split := strings.Split(data, ":") | ||||||
|  |  | ||||||
| 	if user == nil { | 	if len(split) != 2 { | ||||||
| 		return types.UserContext{ | 		return types.UserContext{ | ||||||
| 			Username: "", | 			Email:      "", | ||||||
| 			IsLoggedIn: false, | 			IsLoggedIn: false, | ||||||
|  | 			OAuth:      false, | ||||||
|  | 			Provider:   "", | ||||||
|  | 		}, nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	sessionType := split[0] | ||||||
|  | 	sessionValue := split[1] | ||||||
|  |  | ||||||
|  | 	if sessionType == "email" { | ||||||
|  | 		user := hooks.Auth.GetUser(sessionValue) | ||||||
|  | 		if user == nil { | ||||||
|  | 			return types.UserContext{ | ||||||
|  | 				Email:      "", | ||||||
|  | 				IsLoggedIn: false, | ||||||
|  | 				OAuth:      false, | ||||||
|  | 				Provider:   "", | ||||||
|  | 			}, nil | ||||||
| 		} | 		} | ||||||
|  | 		return types.UserContext{ | ||||||
|  | 			Email:      sessionValue, | ||||||
|  | 			IsLoggedIn: true, | ||||||
|  | 			OAuth:      false, | ||||||
|  | 			Provider:   "", | ||||||
|  | 		}, nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	provider := hooks.Providers.GetProvider(sessionType) | ||||||
|  |  | ||||||
|  | 	if provider == nil { | ||||||
|  | 		return types.UserContext{ | ||||||
|  | 			Email:      "", | ||||||
|  | 			IsLoggedIn: false, | ||||||
|  | 			OAuth:      false, | ||||||
|  | 			Provider:   "", | ||||||
|  | 		}, nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	provider.Token = &oauth2.Token{ | ||||||
|  | 		AccessToken: sessionValue, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	email, emailErr := hooks.Providers.GetUser(sessionType) | ||||||
|  |  | ||||||
|  | 	if emailErr != nil { | ||||||
|  | 		return types.UserContext{ | ||||||
|  | 			Email:      "", | ||||||
|  | 			IsLoggedIn: false, | ||||||
|  | 			OAuth:      false, | ||||||
|  | 			Provider:   "", | ||||||
|  | 		}, nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if !hooks.Auth.EmailWhitelisted(email) { | ||||||
|  | 		session.Delete("tinyauth_sid") | ||||||
|  | 		session.Save() | ||||||
|  | 		return types.UserContext{ | ||||||
|  | 			Email:      "", | ||||||
|  | 			IsLoggedIn: false, | ||||||
|  | 			OAuth:      false, | ||||||
|  | 			Provider:   "", | ||||||
|  | 		}, nil | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return types.UserContext{ | 	return types.UserContext{ | ||||||
| 		Username: username, | 		Email:      email, | ||||||
| 		IsLoggedIn: true, | 		IsLoggedIn: true, | ||||||
| 	} | 		OAuth:      true, | ||||||
|  | 		Provider:   sessionType, | ||||||
|  | 	}, nil | ||||||
| } | } | ||||||
							
								
								
									
										43
									
								
								internal/oauth/oauth.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								internal/oauth/oauth.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | |||||||
|  | package oauth | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"net/http" | ||||||
|  |  | ||||||
|  | 	"golang.org/x/oauth2" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func NewOAuth(config oauth2.Config) *OAuth { | ||||||
|  | 	return &OAuth{ | ||||||
|  | 		Config: config, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type OAuth struct { | ||||||
|  | 	Config   oauth2.Config | ||||||
|  | 	Context  context.Context | ||||||
|  | 	Token    *oauth2.Token | ||||||
|  | 	Verifier string | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (oauth *OAuth) Init() { | ||||||
|  | 	oauth.Context = context.Background() | ||||||
|  | 	oauth.Verifier = oauth2.GenerateVerifier() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (oauth *OAuth) GetAuthURL() string { | ||||||
|  | 	return oauth.Config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(oauth.Verifier)) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (oauth *OAuth) ExchangeToken(code string) (string, error) { | ||||||
|  | 	token, err := oauth.Config.Exchange(oauth.Context, code, oauth2.VerifierOption(oauth.Verifier)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", err | ||||||
|  | 	} | ||||||
|  | 	oauth.Token = token | ||||||
|  | 	return oauth.Token.AccessToken, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (oauth *OAuth) GetClient() *http.Client { | ||||||
|  | 	return oauth.Config.Client(oauth.Context, oauth.Token) | ||||||
|  | } | ||||||
							
								
								
									
										35
									
								
								internal/providers/generic.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								internal/providers/generic.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | |||||||
|  | package providers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"io" | ||||||
|  | 	"net/http" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type GenericUserInfoResponse struct { | ||||||
|  | 	Email string `json:"email"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func GetGenericEmail(client *http.Client, url string) (string, error) { | ||||||
|  | 	res, resErr := client.Get(url) | ||||||
|  |  | ||||||
|  | 	if resErr != nil { | ||||||
|  | 		return "", resErr | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	body, bodyErr := io.ReadAll(res.Body) | ||||||
|  |  | ||||||
|  | 	if bodyErr != nil { | ||||||
|  | 		return "", bodyErr | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var user GenericUserInfoResponse | ||||||
|  |  | ||||||
|  | 	jsonErr := json.Unmarshal(body, &user) | ||||||
|  |  | ||||||
|  | 	if jsonErr != nil { | ||||||
|  | 		return "", jsonErr | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return user.Email, nil | ||||||
|  | } | ||||||
							
								
								
									
										47
									
								
								internal/providers/github.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								internal/providers/github.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | |||||||
|  | package providers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"errors" | ||||||
|  | 	"io" | ||||||
|  | 	"net/http" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type GithubUserInfoResponse []struct { | ||||||
|  | 	Email   string `json:"email"` | ||||||
|  | 	Primary bool   `json:"primary"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func GithubScopes() []string { | ||||||
|  | 	return []string{"user:email"} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func GetGithubEmail(client *http.Client) (string, error) { | ||||||
|  | 	res, resErr := client.Get("https://api.github.com/user/emails") | ||||||
|  |  | ||||||
|  | 	if resErr != nil { | ||||||
|  | 		return "", resErr | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	body, bodyErr := io.ReadAll(res.Body) | ||||||
|  |  | ||||||
|  | 	if bodyErr != nil { | ||||||
|  | 		return "", bodyErr | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var emails GithubUserInfoResponse | ||||||
|  |  | ||||||
|  | 	jsonErr := json.Unmarshal(body, &emails) | ||||||
|  |  | ||||||
|  | 	if jsonErr != nil { | ||||||
|  | 		return "", jsonErr | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	for _, email := range emails { | ||||||
|  | 		if email.Primary { | ||||||
|  | 			return email.Email, nil | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return "", errors.New("no primary email found") | ||||||
|  | } | ||||||
							
								
								
									
										39
									
								
								internal/providers/google.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								internal/providers/google.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | |||||||
|  | package providers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"io" | ||||||
|  | 	"net/http" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type GoogleUserInfoResponse struct { | ||||||
|  | 	Email string `json:"email"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func GoogleScopes() []string { | ||||||
|  | 	return []string{"https://www.googleapis.com/auth/userinfo.email"} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func GetGoogleEmail(client *http.Client) (string, error) { | ||||||
|  | 	res, resErr := client.Get("https://www.googleapis.com/userinfo/v2/me") | ||||||
|  |  | ||||||
|  | 	if resErr != nil { | ||||||
|  | 		return "", resErr | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	body, bodyErr := io.ReadAll(res.Body) | ||||||
|  |  | ||||||
|  | 	if bodyErr != nil { | ||||||
|  | 		return "", bodyErr | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var user GoogleUserInfoResponse | ||||||
|  |  | ||||||
|  | 	jsonErr := json.Unmarshal(body, &user) | ||||||
|  |  | ||||||
|  | 	if jsonErr != nil { | ||||||
|  | 		return "", jsonErr | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return user.Email, nil | ||||||
|  | } | ||||||
							
								
								
									
										127
									
								
								internal/providers/providers.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								internal/providers/providers.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,127 @@ | |||||||
|  | package providers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"tinyauth/internal/oauth" | ||||||
|  | 	"tinyauth/internal/types" | ||||||
|  |  | ||||||
|  | 	"github.com/rs/zerolog/log" | ||||||
|  | 	"golang.org/x/oauth2" | ||||||
|  | 	"golang.org/x/oauth2/endpoints" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func NewProviders(config types.OAuthConfig) *Providers { | ||||||
|  | 	return &Providers{ | ||||||
|  | 		Config: config, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type Providers struct { | ||||||
|  | 	Config  types.OAuthConfig | ||||||
|  | 	Github  *oauth.OAuth | ||||||
|  | 	Google  *oauth.OAuth | ||||||
|  | 	Generic *oauth.OAuth | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (providers *Providers) Init() { | ||||||
|  | 	if providers.Config.GithubClientId != "" && providers.Config.GithubClientSecret != "" { | ||||||
|  | 		log.Info().Msg("Initializing Github OAuth") | ||||||
|  | 		providers.Github = oauth.NewOAuth(oauth2.Config{ | ||||||
|  | 			ClientID:     providers.Config.GithubClientId, | ||||||
|  | 			ClientSecret: providers.Config.GithubClientSecret, | ||||||
|  | 			RedirectURL:  fmt.Sprintf("%s/api/oauth/callback/github", providers.Config.AppURL), | ||||||
|  | 			Scopes:       GithubScopes(), | ||||||
|  | 			Endpoint:     endpoints.GitHub, | ||||||
|  | 		}) | ||||||
|  | 		providers.Github.Init() | ||||||
|  | 	} | ||||||
|  | 	if providers.Config.GoogleClientId != "" && providers.Config.GoogleClientSecret != "" { | ||||||
|  | 		log.Info().Msg("Initializing Google OAuth") | ||||||
|  | 		providers.Google = oauth.NewOAuth(oauth2.Config{ | ||||||
|  | 			ClientID:     providers.Config.GoogleClientId, | ||||||
|  | 			ClientSecret: providers.Config.GoogleClientSecret, | ||||||
|  | 			RedirectURL:  fmt.Sprintf("%s/api/oauth/callback/google", providers.Config.AppURL), | ||||||
|  | 			Scopes:       GoogleScopes(), | ||||||
|  | 			Endpoint:     endpoints.Google, | ||||||
|  | 		}) | ||||||
|  | 		providers.Google.Init() | ||||||
|  | 	} | ||||||
|  | 	if providers.Config.GenericClientId != "" && providers.Config.GenericClientSecret != "" { | ||||||
|  | 		log.Info().Msg("Initializing Generic OAuth") | ||||||
|  | 		providers.Generic = oauth.NewOAuth(oauth2.Config{ | ||||||
|  | 			ClientID:     providers.Config.GenericClientId, | ||||||
|  | 			ClientSecret: providers.Config.GenericClientSecret, | ||||||
|  | 			RedirectURL:  fmt.Sprintf("%s/api/oauth/callback/generic", providers.Config.AppURL), | ||||||
|  | 			Scopes:       providers.Config.GenericScopes, | ||||||
|  | 			Endpoint: oauth2.Endpoint{ | ||||||
|  | 				AuthURL:  providers.Config.GenericAuthURL, | ||||||
|  | 				TokenURL: providers.Config.GenericTokenURL, | ||||||
|  | 			}, | ||||||
|  | 		}) | ||||||
|  | 		providers.Generic.Init() | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (providers *Providers) GetProvider(provider string) *oauth.OAuth { | ||||||
|  | 	switch provider { | ||||||
|  | 	case "github": | ||||||
|  | 		return providers.Github | ||||||
|  | 	case "google": | ||||||
|  | 		return providers.Google | ||||||
|  | 	case "generic": | ||||||
|  | 		return providers.Generic | ||||||
|  | 	default: | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (providers *Providers) GetUser(provider string) (string, error) { | ||||||
|  | 	switch provider { | ||||||
|  | 	case "github": | ||||||
|  | 		if providers.Github == nil { | ||||||
|  | 			return "", nil | ||||||
|  | 		} | ||||||
|  | 		client := providers.Github.GetClient() | ||||||
|  | 		email, emailErr := GetGithubEmail(client) | ||||||
|  | 		if emailErr != nil { | ||||||
|  | 			return "", emailErr | ||||||
|  | 		} | ||||||
|  | 		return email, nil | ||||||
|  | 	case "google": | ||||||
|  | 		if providers.Google == nil { | ||||||
|  | 			return "", nil | ||||||
|  | 		} | ||||||
|  | 		client := providers.Google.GetClient() | ||||||
|  | 		email, emailErr := GetGoogleEmail(client) | ||||||
|  | 		if emailErr != nil { | ||||||
|  | 			return "", emailErr | ||||||
|  | 		} | ||||||
|  | 		return email, nil | ||||||
|  | 	case "generic": | ||||||
|  | 		if providers.Generic == nil { | ||||||
|  | 			return "", nil | ||||||
|  | 		} | ||||||
|  | 		client := providers.Generic.GetClient() | ||||||
|  | 		email, emailErr := GetGenericEmail(client, providers.Config.GenericUserURL) | ||||||
|  | 		if emailErr != nil { | ||||||
|  | 			return "", emailErr | ||||||
|  | 		} | ||||||
|  | 		return email, nil | ||||||
|  | 	default: | ||||||
|  | 		return "", nil | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (provider *Providers) GetConfiguredProviders() []string { | ||||||
|  | 	providers := []string{} | ||||||
|  | 	if provider.Github != nil { | ||||||
|  | 		providers = append(providers, "github") | ||||||
|  | 	} | ||||||
|  | 	if provider.Google != nil { | ||||||
|  | 		providers = append(providers, "google") | ||||||
|  | 	} | ||||||
|  | 	if provider.Generic != nil { | ||||||
|  | 		providers = append(providers, "generic") | ||||||
|  | 	} | ||||||
|  | 	return providers | ||||||
|  | } | ||||||
| @@ -1,40 +1,87 @@ | |||||||
| package types | package types | ||||||
|  |  | ||||||
|  | import "tinyauth/internal/oauth" | ||||||
|  |  | ||||||
| type LoginQuery struct { | type LoginQuery struct { | ||||||
| 	RedirectURI string `url:"redirect_uri"` | 	RedirectURI string `url:"redirect_uri"` | ||||||
| } | } | ||||||
|  |  | ||||||
| type LoginRequest struct { | type LoginRequest struct { | ||||||
| 	Username string `json:"username"` | 	Email    string `json:"email"` | ||||||
| 	Password string `json:"password"` | 	Password string `json:"password"` | ||||||
| } | } | ||||||
|  |  | ||||||
| type User struct { | type User struct { | ||||||
| 	Username string | 	Email    string | ||||||
| 	Password string | 	Password string | ||||||
| } | } | ||||||
|  |  | ||||||
| type Users []User | type Users []User | ||||||
|  |  | ||||||
| type Config struct { | type Config struct { | ||||||
| 	Port int `validate:"number" mapstructure:"port"` | 	Port                int    `validate:"number" mapstructure:"port"` | ||||||
| 	Address string `mapstructure:"address, ip4_addr"` | 	Address             string `mapstructure:"address, ip4_addr"` | ||||||
| 	Secret string `validate:"required,len=32" mapstructure:"secret"` | 	Secret              string `validate:"required,len=32" mapstructure:"secret"` | ||||||
| 	AppURL string `validate:"required,url" mapstructure:"app-url"` | 	AppURL              string `validate:"required,url" mapstructure:"app-url"` | ||||||
| 	Users string `mapstructure:"users"` | 	Users               string `mapstructure:"users"` | ||||||
| 	UsersFile string `mapstructure:"users-file"` | 	UsersFile           string `mapstructure:"users-file"` | ||||||
| 	CookieSecure bool `mapstructure:"cookie-secure"` | 	CookieSecure        bool   `mapstructure:"cookie-secure"` | ||||||
|  | 	GithubClientId      string `mapstructure:"github-client-id"` | ||||||
|  | 	GithubClientSecret  string `mapstructure:"github-client-secret"` | ||||||
|  | 	GoogleClientId      string `mapstructure:"google-client-id"` | ||||||
|  | 	GoogleClientSecret  string `mapstructure:"google-client-secret"` | ||||||
|  | 	GenericClientId     string `mapstructure:"generic-client-id"` | ||||||
|  | 	GenericClientSecret string `mapstructure:"generic-client-secret"` | ||||||
|  | 	GenericScopes       string `mapstructure:"generic-scopes"` | ||||||
|  | 	GenericAuthURL      string `mapstructure:"generic-auth-url"` | ||||||
|  | 	GenericTokenURL     string `mapstructure:"generic-token-url"` | ||||||
|  | 	GenericUserURL      string `mapstructure:"generic-user-info-url"` | ||||||
|  | 	DisableContinue     bool   `mapstructure:"disable-continue"` | ||||||
|  | 	OAuthWhitelist      string `mapstructure:"oauth-whitelist"` | ||||||
|  | 	CookieExpiry        int    `mapstructure:"cookie-expiry"` | ||||||
| } | } | ||||||
|  |  | ||||||
| type UserContext struct { | type UserContext struct { | ||||||
| 	Username string | 	Email      string | ||||||
| 	IsLoggedIn bool | 	IsLoggedIn bool | ||||||
|  | 	OAuth      bool | ||||||
|  | 	Provider   string | ||||||
| } | } | ||||||
|  |  | ||||||
| type APIConfig struct { | type APIConfig struct { | ||||||
| 	Port int | 	Port            int | ||||||
| 	Address string | 	Address         string | ||||||
| 	Secret string | 	Secret          string | ||||||
| 	AppURL string | 	AppURL          string | ||||||
| 	CookieSecure bool | 	CookieSecure    bool | ||||||
|  | 	CookieExpiry    int | ||||||
|  | 	DisableContinue bool | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type OAuthConfig struct { | ||||||
|  | 	GithubClientId      string | ||||||
|  | 	GithubClientSecret  string | ||||||
|  | 	GoogleClientId      string | ||||||
|  | 	GoogleClientSecret  string | ||||||
|  | 	GenericClientId     string | ||||||
|  | 	GenericClientSecret string | ||||||
|  | 	GenericScopes       []string | ||||||
|  | 	GenericAuthURL      string | ||||||
|  | 	GenericTokenURL     string | ||||||
|  | 	GenericUserURL      string | ||||||
|  | 	AppURL              string | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type OAuthRequest struct { | ||||||
|  | 	Provider string `uri:"provider" binding:"required"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type OAuthProviders struct { | ||||||
|  | 	Github    *oauth.OAuth | ||||||
|  | 	Google    *oauth.OAuth | ||||||
|  | 	Microsoft *oauth.OAuth | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type UnauthorizedQuery struct { | ||||||
|  | 	Email string `url:"email"` | ||||||
| } | } | ||||||
| @@ -22,7 +22,7 @@ func ParseUsers(users string) (types.Users, error) { | |||||||
| 			return types.Users{}, errors.New("invalid user format") | 			return types.Users{}, errors.New("invalid user format") | ||||||
| 		} | 		} | ||||||
| 		usersParsed = append(usersParsed, types.User{ | 		usersParsed = append(usersParsed, types.User{ | ||||||
| 			Username: userSplit[0], | 			Email:    userSplit[0], | ||||||
| 			Password: userSplit[1], | 			Password: userSplit[1], | ||||||
| 		}) | 		}) | ||||||
| 	} | 	} | ||||||
| @@ -59,3 +59,25 @@ func GetUsersFromFile(usersFile string) (string, error) { | |||||||
|  |  | ||||||
| 	return string(data), nil | 	return string(data), nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func ParseFileToLine(content string) string { | ||||||
|  | 	lines := strings.Split(content, "\n") | ||||||
|  | 	users := make([]string, 0) | ||||||
|  |  | ||||||
|  | 	for _, line := range lines { | ||||||
|  | 		if strings.TrimSpace(line) == "" { | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		users = append(users, line) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return strings.Join(users, ",") | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func ParseCommaString(str string) []string { | ||||||
|  | 	if str == "" { | ||||||
|  | 		return []string{} | ||||||
|  | 	} | ||||||
|  | 	return strings.Split(str, ",") | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								site/src/icons/github.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								site/src/icons/github.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | import type { SVGProps } from "react"; | ||||||
|  |  | ||||||
|  | export function GithubIcon(props: SVGProps<SVGSVGElement>) { | ||||||
|  |   return ( | ||||||
|  |     <svg | ||||||
|  |       xmlns="http://www.w3.org/2000/svg" | ||||||
|  |       width={24} | ||||||
|  |       height={24} | ||||||
|  |       viewBox="0 0 24 24" | ||||||
|  |       {...props} | ||||||
|  |     > | ||||||
|  |       <path | ||||||
|  |         fill="currentColor" | ||||||
|  |         d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2" | ||||||
|  |       ></path> | ||||||
|  |     </svg> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										30
									
								
								site/src/icons/google.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								site/src/icons/google.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | |||||||
|  | import type { SVGProps } from "react"; | ||||||
|  |  | ||||||
|  | export function GoogleIcon(props: SVGProps<SVGSVGElement>) { | ||||||
|  |   return ( | ||||||
|  |     <svg | ||||||
|  |       xmlns="http://www.w3.org/2000/svg" | ||||||
|  |       width={48} | ||||||
|  |       height={48} | ||||||
|  |       viewBox="0 0 48 48" | ||||||
|  |       {...props} | ||||||
|  |     > | ||||||
|  |       <path | ||||||
|  |         fill="#ffc107" | ||||||
|  |         d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8c-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C12.955 4 4 12.955 4 24s8.955 20 20 20s20-8.955 20-20c0-1.341-.138-2.65-.389-3.917" | ||||||
|  |       ></path> | ||||||
|  |       <path | ||||||
|  |         fill="#ff3d00" | ||||||
|  |         d="m6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C16.318 4 9.656 8.337 6.306 14.691" | ||||||
|  |       ></path> | ||||||
|  |       <path | ||||||
|  |         fill="#4caf50" | ||||||
|  |         d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.9 11.9 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44" | ||||||
|  |       ></path> | ||||||
|  |       <path | ||||||
|  |         fill="#1976d2" | ||||||
|  |         d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002l6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917" | ||||||
|  |       ></path> | ||||||
|  |     </svg> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										24
									
								
								site/src/icons/oauth.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								site/src/icons/oauth.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | |||||||
|  | import type { SVGProps } from "react"; | ||||||
|  |  | ||||||
|  | export function OAuthIcon(props: SVGProps<SVGSVGElement>) { | ||||||
|  |   return ( | ||||||
|  |     <svg | ||||||
|  |       xmlns="http://www.w3.org/2000/svg" | ||||||
|  |       width={24} | ||||||
|  |       height={24} | ||||||
|  |       viewBox="0 0 24 24" | ||||||
|  |       {...props} | ||||||
|  |     > | ||||||
|  |       <g | ||||||
|  |         fill="none" | ||||||
|  |         stroke="currentColor" | ||||||
|  |         strokeLinecap="round" | ||||||
|  |         strokeLinejoin="round" | ||||||
|  |         strokeWidth={2} | ||||||
|  |       > | ||||||
|  |         <path d="M2 12a10 10 0 1 0 20 0a10 10 0 1 0-20 0"></path> | ||||||
|  |         <path d="M12.556 6c.65 0 1.235.373 1.508.947l2.839 7.848a1.646 1.646 0 0 1-1.01 2.108a1.673 1.673 0 0 1-2.068-.851L13.365 15h-2.73l-.398.905A1.67 1.67 0 0 1 8.26 16.95l-.153-.047a1.647 1.647 0 0 1-1.056-1.956l2.824-7.852a1.66 1.66 0 0 1 1.409-1.087z"></path> | ||||||
|  |       </g> | ||||||
|  |     </svg> | ||||||
|  |   ); | ||||||
|  | } | ||||||
| @@ -13,6 +13,8 @@ import { LoginPage } from "./pages/login-page.tsx"; | |||||||
| import { LogoutPage } from "./pages/logout-page.tsx"; | import { LogoutPage } from "./pages/logout-page.tsx"; | ||||||
| import { ContinuePage } from "./pages/continue-page.tsx"; | import { ContinuePage } from "./pages/continue-page.tsx"; | ||||||
| import { NotFoundPage } from "./pages/not-found-page.tsx"; | import { NotFoundPage } from "./pages/not-found-page.tsx"; | ||||||
|  | import { UnauthorizedPage } from "./pages/unauthorized-page.tsx"; | ||||||
|  | import { InternalServerError } from "./pages/internal-server-error.tsx"; | ||||||
|  |  | ||||||
| const queryClient = new QueryClient({ | const queryClient = new QueryClient({ | ||||||
|   defaultOptions: { |   defaultOptions: { | ||||||
| @@ -34,6 +36,8 @@ createRoot(document.getElementById("root")!).render( | |||||||
|               <Route path="/login" element={<LoginPage />} /> |               <Route path="/login" element={<LoginPage />} /> | ||||||
|               <Route path="/logout" element={<LogoutPage />} /> |               <Route path="/logout" element={<LogoutPage />} /> | ||||||
|               <Route path="/continue" element={<ContinuePage />} /> |               <Route path="/continue" element={<ContinuePage />} /> | ||||||
|  |               <Route path="/unauthorized" element={<UnauthorizedPage />} /> | ||||||
|  |               <Route path="/error" element={<InternalServerError />} /> | ||||||
|               <Route path="*" element={<NotFoundPage />} /> |               <Route path="*" element={<NotFoundPage />} /> | ||||||
|             </Routes> |             </Routes> | ||||||
|           </BrowserRouter> |           </BrowserRouter> | ||||||
|   | |||||||
| @@ -9,12 +9,16 @@ export const ContinuePage = () => { | |||||||
|   const params = new URLSearchParams(queryString); |   const params = new URLSearchParams(queryString); | ||||||
|   const redirectUri = params.get("redirect_uri"); |   const redirectUri = params.get("redirect_uri"); | ||||||
|  |  | ||||||
|   const { isLoggedIn } = useUserContext(); |   const { isLoggedIn, disableContinue } = useUserContext(); | ||||||
|  |  | ||||||
|   if (!isLoggedIn) { |   if (!isLoggedIn) { | ||||||
|     return <Navigate to="/login" />; |     return <Navigate to="/login" />; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   if (disableContinue && redirectUri !== "null") { | ||||||
|  |     window.location.replace(redirectUri!); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   const redirect = () => { |   const redirect = () => { | ||||||
|     notifications.show({ |     notifications.show({ | ||||||
|       title: "Redirecting", |       title: "Redirecting", | ||||||
|   | |||||||
							
								
								
									
										21
									
								
								site/src/pages/internal-server-error.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								site/src/pages/internal-server-error.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | import { Button, Paper, Text } from "@mantine/core"; | ||||||
|  | import { Layout } from "../components/layouts/layout"; | ||||||
|  |  | ||||||
|  | export const InternalServerError = () => { | ||||||
|  |   return ( | ||||||
|  |     <Layout> | ||||||
|  |       <Paper shadow="md" p={30} mt={30} radius="md" withBorder> | ||||||
|  |         <Text size="xl" fw={700}> | ||||||
|  |           Internal Server Error | ||||||
|  |         </Text> | ||||||
|  |         <Text> | ||||||
|  |           An error occured on the server and it currently cannot serve your | ||||||
|  |           request. | ||||||
|  |         </Text> | ||||||
|  |         <Button fullWidth mt="xl" onClick={() => window.location.replace("/")}> | ||||||
|  |           Try again | ||||||
|  |         </Button> | ||||||
|  |       </Paper> | ||||||
|  |     </Layout> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -1,4 +1,13 @@ | |||||||
| import { Button, Paper, PasswordInput, TextInput, Title } from "@mantine/core"; | import { | ||||||
|  |   Button, | ||||||
|  |   Paper, | ||||||
|  |   PasswordInput, | ||||||
|  |   TextInput, | ||||||
|  |   Title, | ||||||
|  |   Text, | ||||||
|  |   Divider, | ||||||
|  |   Grid, | ||||||
|  | } from "@mantine/core"; | ||||||
| import { useForm, zodResolver } from "@mantine/form"; | import { useForm, zodResolver } from "@mantine/form"; | ||||||
| import { notifications } from "@mantine/notifications"; | import { notifications } from "@mantine/notifications"; | ||||||
| import { useMutation } from "@tanstack/react-query"; | import { useMutation } from "@tanstack/react-query"; | ||||||
| @@ -7,20 +16,23 @@ import { z } from "zod"; | |||||||
| import { useUserContext } from "../context/user-context"; | import { useUserContext } from "../context/user-context"; | ||||||
| import { Navigate } from "react-router"; | import { Navigate } from "react-router"; | ||||||
| import { Layout } from "../components/layouts/layout"; | import { Layout } from "../components/layouts/layout"; | ||||||
|  | import { GoogleIcon } from "../icons/google"; | ||||||
|  | import { GithubIcon } from "../icons/github"; | ||||||
|  | import { OAuthIcon } from "../icons/oauth"; | ||||||
|  |  | ||||||
| export const LoginPage = () => { | export const LoginPage = () => { | ||||||
|   const queryString = window.location.search; |   const queryString = window.location.search; | ||||||
|   const params = new URLSearchParams(queryString); |   const params = new URLSearchParams(queryString); | ||||||
|   const redirectUri = params.get("redirect_uri"); |   const redirectUri = params.get("redirect_uri"); | ||||||
|  |  | ||||||
|   const { isLoggedIn } = useUserContext(); |   const { isLoggedIn, configuredProviders } = useUserContext(); | ||||||
|  |  | ||||||
|   if (isLoggedIn) { |   if (isLoggedIn) { | ||||||
|     return <Navigate to="/logout" />; |     return <Navigate to="/logout" />; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const schema = z.object({ |   const schema = z.object({ | ||||||
|     username: z.string(), |     email: z.string().email(), | ||||||
|     password: z.string(), |     password: z.string(), | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
| @@ -29,7 +41,7 @@ export const LoginPage = () => { | |||||||
|   const form = useForm({ |   const form = useForm({ | ||||||
|     mode: "uncontrolled", |     mode: "uncontrolled", | ||||||
|     initialValues: { |     initialValues: { | ||||||
|       username: "", |       email: "", | ||||||
|       password: "", |       password: "", | ||||||
|     }, |     }, | ||||||
|     validate: zodResolver(schema), |     validate: zodResolver(schema), | ||||||
| @@ -42,7 +54,7 @@ export const LoginPage = () => { | |||||||
|     onError: () => { |     onError: () => { | ||||||
|       notifications.show({ |       notifications.show({ | ||||||
|         title: "Failed to login", |         title: "Failed to login", | ||||||
|         message: "Check your username and password", |         message: "Check your email and password", | ||||||
|         color: "red", |         color: "red", | ||||||
|       }); |       }); | ||||||
|     }, |     }, | ||||||
| @@ -58,22 +70,104 @@ export const LoginPage = () => { | |||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   const loginOAuthMutation = useMutation({ | ||||||
|  |     mutationFn: (provider: string) => { | ||||||
|  |       return axios.get( | ||||||
|  |         `/api/oauth/url/${provider}?redirect_uri=${redirectUri}`, | ||||||
|  |       ); | ||||||
|  |     }, | ||||||
|  |     onError: () => { | ||||||
|  |       notifications.show({ | ||||||
|  |         title: "Internal error", | ||||||
|  |         message: "Failed to get OAuth URL", | ||||||
|  |         color: "red", | ||||||
|  |       }); | ||||||
|  |     }, | ||||||
|  |     onSuccess: (data) => { | ||||||
|  |       window.location.replace(data.data.url); | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const handleSubmit = (values: FormValues) => { |   const handleSubmit = (values: FormValues) => { | ||||||
|     loginMutation.mutate(values); |     loginMutation.mutate(values); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Layout> |     <Layout> | ||||||
|       <Title ta="center">Welcome back!</Title> |       <Title ta="center">Tinyauth</Title> | ||||||
|       <Paper shadow="md" p={30} mt={30} radius="md" withBorder> |       <Paper shadow="md" p="xl" mt={30} radius="md" withBorder> | ||||||
|  |         {configuredProviders.length === 0 && ( | ||||||
|  |           <Text size="lg" mb="md" fw={500} ta="center"> | ||||||
|  |             Welcome back, please login | ||||||
|  |           </Text> | ||||||
|  |         )} | ||||||
|  |         {configuredProviders.length > 0 && ( | ||||||
|  |           <> | ||||||
|  |             <Text size="lg" fw={500} ta="center"> | ||||||
|  |               Welcome back, login with | ||||||
|  |             </Text> | ||||||
|  |             <Grid mb="md" mt="md" align="center" justify="center"> | ||||||
|  |               {configuredProviders.includes("google") && ( | ||||||
|  |                 <Grid.Col span="content"> | ||||||
|  |                   <Button | ||||||
|  |                     radius="xl" | ||||||
|  |                     leftSection={ | ||||||
|  |                       <GoogleIcon style={{ width: 14, height: 14 }} /> | ||||||
|  |                     } | ||||||
|  |                     variant="default" | ||||||
|  |                     onClick={() => loginOAuthMutation.mutate("google")} | ||||||
|  |                     loading={loginOAuthMutation.isLoading} | ||||||
|  |                   > | ||||||
|  |                     Google | ||||||
|  |                   </Button> | ||||||
|  |                 </Grid.Col> | ||||||
|  |               )} | ||||||
|  |               {configuredProviders.includes("github") && ( | ||||||
|  |                 <Grid.Col span="content"> | ||||||
|  |                   <Button | ||||||
|  |                     radius="xl" | ||||||
|  |                     leftSection={ | ||||||
|  |                       <GithubIcon style={{ width: 14, height: 14 }} /> | ||||||
|  |                     } | ||||||
|  |                     variant="default" | ||||||
|  |                     onClick={() => loginOAuthMutation.mutate("github")} | ||||||
|  |                     loading={loginOAuthMutation.isLoading} | ||||||
|  |                   > | ||||||
|  |                     Github | ||||||
|  |                   </Button> | ||||||
|  |                 </Grid.Col> | ||||||
|  |               )} | ||||||
|  |               {configuredProviders.includes("generic") && ( | ||||||
|  |                 <Grid.Col span="content"> | ||||||
|  |                   <Button | ||||||
|  |                     radius="xl" | ||||||
|  |                     leftSection={ | ||||||
|  |                       <OAuthIcon style={{ width: 14, height: 14 }} /> | ||||||
|  |                     } | ||||||
|  |                     variant="default" | ||||||
|  |                     onClick={() => loginOAuthMutation.mutate("generic")} | ||||||
|  |                     loading={loginOAuthMutation.isLoading} | ||||||
|  |                   > | ||||||
|  |                     Generic | ||||||
|  |                   </Button> | ||||||
|  |                 </Grid.Col> | ||||||
|  |               )} | ||||||
|  |             </Grid> | ||||||
|  |             <Divider | ||||||
|  |               label="Or continue with email" | ||||||
|  |               labelPosition="center" | ||||||
|  |               my="lg" | ||||||
|  |             /> | ||||||
|  |           </> | ||||||
|  |         )} | ||||||
|         <form onSubmit={form.onSubmit(handleSubmit)}> |         <form onSubmit={form.onSubmit(handleSubmit)}> | ||||||
|           <TextInput |           <TextInput | ||||||
|             label="Username" |             label="Email" | ||||||
|             placeholder="tinyauth" |             placeholder="user@example.com" | ||||||
|             required |             required | ||||||
|             disabled={loginMutation.isLoading} |             disabled={loginMutation.isLoading} | ||||||
|             key={form.key("username")} |             key={form.key("email")} | ||||||
|             {...form.getInputProps("username")} |             {...form.getInputProps("email")} | ||||||
|           /> |           /> | ||||||
|           <PasswordInput |           <PasswordInput | ||||||
|             label="Password" |             label="Password" | ||||||
| @@ -90,7 +184,7 @@ export const LoginPage = () => { | |||||||
|             type="submit" |             type="submit" | ||||||
|             loading={loginMutation.isLoading} |             loading={loginMutation.isLoading} | ||||||
|           > |           > | ||||||
|             Sign in |             Login | ||||||
|           </Button> |           </Button> | ||||||
|         </form> |         </form> | ||||||
|       </Paper> |       </Paper> | ||||||
|   | |||||||
| @@ -5,9 +5,10 @@ import axios from "axios"; | |||||||
| import { useUserContext } from "../context/user-context"; | import { useUserContext } from "../context/user-context"; | ||||||
| import { Navigate } from "react-router"; | import { Navigate } from "react-router"; | ||||||
| import { Layout } from "../components/layouts/layout"; | import { Layout } from "../components/layouts/layout"; | ||||||
|  | import { capitalize } from "../utils/utils"; | ||||||
|  |  | ||||||
| export const LogoutPage = () => { | export const LogoutPage = () => { | ||||||
|   const { isLoggedIn, username } = useUserContext(); |   const { isLoggedIn, email, oauth, provider } = useUserContext(); | ||||||
|  |  | ||||||
|   if (!isLoggedIn) { |   if (!isLoggedIn) { | ||||||
|     return <Navigate to="/login" />; |     return <Navigate to="/login" />; | ||||||
| @@ -43,8 +44,9 @@ export const LogoutPage = () => { | |||||||
|           Logout |           Logout | ||||||
|         </Text> |         </Text> | ||||||
|         <Text> |         <Text> | ||||||
|           You are currently logged in as <Code>{username}</Code>, click the |           You are currently logged in as <Code>{email}</Code> | ||||||
|           button below to log out. |           {oauth && ` using ${capitalize(provider)}`}. Click the button below to | ||||||
|  |           log out. | ||||||
|         </Text> |         </Text> | ||||||
|         <Button |         <Button | ||||||
|           fullWidth |           fullWidth | ||||||
|   | |||||||
							
								
								
									
										41
									
								
								site/src/pages/unauthorized-page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								site/src/pages/unauthorized-page.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | |||||||
|  | import { Button, Code, Paper, Text } from "@mantine/core"; | ||||||
|  | import { Layout } from "../components/layouts/layout"; | ||||||
|  | import { useUserContext } from "../context/user-context"; | ||||||
|  | import { Navigate } from "react-router"; | ||||||
|  |  | ||||||
|  | export const UnauthorizedPage = () => { | ||||||
|  |   const queryString = window.location.search; | ||||||
|  |   const params = new URLSearchParams(queryString); | ||||||
|  |   const email = params.get("email"); | ||||||
|  |  | ||||||
|  |   const { isLoggedIn } = useUserContext(); | ||||||
|  |  | ||||||
|  |   if (isLoggedIn) { | ||||||
|  |     return <Navigate to="/" />; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (email === "null") { | ||||||
|  |     return <Navigate to="/" />; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Layout> | ||||||
|  |       <Paper shadow="md" p={30} mt={30} radius="md" withBorder> | ||||||
|  |         <Text size="xl" fw={700}> | ||||||
|  |           Unauthorized | ||||||
|  |         </Text> | ||||||
|  |         <Text> | ||||||
|  |           The user with email address <Code>{email}</Code> is not authorized to | ||||||
|  |           login. | ||||||
|  |         </Text> | ||||||
|  |         <Button | ||||||
|  |           fullWidth | ||||||
|  |           mt="xl" | ||||||
|  |           onClick={() => window.location.replace("/login")} | ||||||
|  |         > | ||||||
|  |           Try again | ||||||
|  |         </Button> | ||||||
|  |       </Paper> | ||||||
|  |     </Layout> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -2,7 +2,11 @@ import { z } from "zod"; | |||||||
|  |  | ||||||
| export const userContextSchema = z.object({ | export const userContextSchema = z.object({ | ||||||
|   isLoggedIn: z.boolean(), |   isLoggedIn: z.boolean(), | ||||||
|   username: z.string(), |   email: z.string(), | ||||||
|  |   oauth: z.boolean(), | ||||||
|  |   provider: z.string(), | ||||||
|  |   configuredProviders: z.array(z.string()), | ||||||
|  |   disableContinue: z.boolean(), | ||||||
| }); | }); | ||||||
|  |  | ||||||
| export type UserContextSchemaType = z.infer<typeof userContextSchema>; | export type UserContextSchemaType = z.infer<typeof userContextSchema>; | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								site/src/utils/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								site/src/utils/utils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); | ||||||
		Reference in New Issue
	
	Block a user