mirror of
				https://github.com/steveiliop56/tinyauth.git
				synced 2025-10-31 14:15:50 +00:00 
			
		
		
		
	Compare commits
	
		
			24 Commits
		
	
	
		
			v0.3.0-bet
			...
			v1.0.0
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 47d8f1e5aa | ||
|   | e8d2e059a9 | ||
|   | 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 --from=site-builder /site/dist ./internal/assets/dist | ||||
|  | ||||
| RUN go build | ||||
| RUN CGO_ENABLED=0 go build | ||||
|  | ||||
| # Runner | ||||
| FROM busybox:1.37-musl AS runner | ||||
| FROM alpine:3.21 AS runner | ||||
|  | ||||
| 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? | ||||
|  | ||||
| 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. | ||||
| You can find documentation and guides on all available configuration of tinyauth [here](https://tinyauth.doesmycode.work). | ||||
|  | ||||
| ## 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 | ||||
|  | ||||
| Credits for the logo go to: | ||||
| Credits for the logo of this app go to: | ||||
|  | ||||
| - Freepik for providing the hat and police badge. | ||||
| - Renee French for making the gopher logo. | ||||
| - **Freepik** for providing the police hat and logo. | ||||
| - **Renee French** for the original gopher logo. | ||||
|   | ||||
							
								
								
									
										74
									
								
								cmd/root.go
									
									
									
									
									
								
							
							
						
						
									
										74
									
								
								cmd/root.go
									
									
									
									
									
								
							| @@ -1,12 +1,11 @@ | ||||
| package cmd | ||||
|  | ||||
| import ( | ||||
| 	"os" | ||||
| 	"strings" | ||||
| 	cmd "tinyauth/cmd/user" | ||||
| 	"tinyauth/internal/api" | ||||
| 	"tinyauth/internal/auth" | ||||
| 	"tinyauth/internal/hooks" | ||||
| 	"tinyauth/internal/providers" | ||||
| 	"tinyauth/internal/types" | ||||
| 	"tinyauth/internal/utils" | ||||
|  | ||||
| @@ -38,7 +37,6 @@ var rootCmd = &cobra.Command{ | ||||
|  | ||||
| 		if config.UsersFile == "" && config.Users == "" { | ||||
| 			log.Fatal().Msg("No users provided") | ||||
| 			os.Exit(1) | ||||
| 		} | ||||
|  | ||||
| 		usersString := config.Users | ||||
| @@ -47,7 +45,7 @@ var rootCmd = &cobra.Command{ | ||||
| 			log.Info().Msg("Reading users from file") | ||||
| 			usersFromFile, readErr := utils.GetUsersFromFile(config.UsersFile) | ||||
| 			HandleError(readErr, "Failed to read users from file") | ||||
| 			usersFromFileParsed := strings.Join(strings.Split(usersFromFile, "\n"), ",") | ||||
| 			usersFromFileParsed := utils.ParseFileToLine(usersFromFile) | ||||
| 			if usersString != "" { | ||||
| 				usersString = usersString + "," + usersFromFileParsed | ||||
| 			} else { | ||||
| @@ -58,11 +56,35 @@ var rootCmd = &cobra.Command{ | ||||
| 		users, parseErr := utils.ParseUsers(usersString) | ||||
| 		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 | ||||
| 		auth := auth.NewAuth(users) | ||||
| 		auth := auth.NewAuth(users, oauthWhitelist) | ||||
|  | ||||
| 		// Create OAuth providers service | ||||
| 		providers := providers.NewProviders(oauthConfig) | ||||
|  | ||||
| 		// Initialize providers | ||||
| 		providers.Init() | ||||
|  | ||||
| 		// Create hooks service | ||||
| 		hooks := hooks.NewHooks(auth) | ||||
| 		hooks := hooks.NewHooks(auth, providers) | ||||
|  | ||||
| 		// Create API | ||||
| 		api := api.NewAPI(types.APIConfig{ | ||||
| @@ -71,7 +93,9 @@ var rootCmd = &cobra.Command{ | ||||
| 			Secret:          config.Secret, | ||||
| 			AppURL:          config.AppURL, | ||||
| 			CookieSecure:    config.CookieSecure, | ||||
| 		}, hooks, auth) | ||||
| 			DisableContinue: config.DisableContinue, | ||||
| 			CookieExpiry:    config.CookieExpiry, | ||||
| 		}, hooks, auth, providers) | ||||
|  | ||||
| 		// Setup routes | ||||
| 		api.Init() | ||||
| @@ -86,31 +110,57 @@ func Execute() { | ||||
| 	err := rootCmd.Execute() | ||||
| 	if err != nil { | ||||
| 		log.Fatal().Err(err).Msg("Failed to execute command") | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func HandleError(err error, msg string) { | ||||
| 	if err != nil { | ||||
| 		log.Fatal().Err(err).Msg(msg) | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func init() { | ||||
| 	rootCmd.AddCommand(cmd.UserCmd()) | ||||
| 	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("secret", "", "Secret to use for the cookie.") | ||||
| 	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-file", "", "Path to a file containing users in the format username:bcrypt-hashed-password.") | ||||
| 	rootCmd.Flags().String("users", "", "Comma separated list of users in the format email:hash.") | ||||
| 	rootCmd.Flags().String("users-file", "", "Path to a file containing users in the format email:hash.") | ||||
| 	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("address", "ADDRESS") | ||||
| 	viper.BindEnv("secret", "SECRET") | ||||
| 	viper.BindEnv("app-url", "APP_URL") | ||||
| 	viper.BindEnv("users", "USERS") | ||||
| 	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()) | ||||
| } | ||||
|   | ||||
| @@ -3,7 +3,6 @@ package create | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/charmbracelet/huh" | ||||
| @@ -13,7 +12,7 @@ import ( | ||||
| ) | ||||
|  | ||||
| var interactive bool | ||||
| var username string | ||||
| var email string | ||||
| var password string | ||||
| var docker bool | ||||
|  | ||||
| @@ -25,9 +24,9 @@ var CreateCmd = &cobra.Command{ | ||||
| 		if interactive { | ||||
| 			form := huh.NewForm( | ||||
| 				huh.NewGroup( | ||||
| 					huh.NewInput().Title("Username").Value(&username).Validate((func(s string) error { | ||||
| 					huh.NewInput().Title("Email").Value(&email).Validate((func(s string) error { | ||||
| 						if s == "" { | ||||
| 							return errors.New("username cannot be empty") | ||||
| 							return errors.New("email cannot be empty") | ||||
| 						} | ||||
| 						return nil | ||||
| 					})), | ||||
| @@ -47,22 +46,19 @@ var CreateCmd = &cobra.Command{ | ||||
|  | ||||
| 			if formErr != nil { | ||||
| 				log.Fatal().Err(formErr).Msg("Form failed") | ||||
| 				os.Exit(1) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if username == "" || password == "" { | ||||
| 			log.Error().Msg("Username and password cannot be empty") | ||||
| 			os.Exit(1) | ||||
| 		if email == "" || password == "" { | ||||
| 			log.Error().Msg("Email and password cannot be empty") | ||||
| 		} | ||||
|  | ||||
| 		log.Info().Str("username", username).Str("password", password).Bool("docker", docker).Msg("Creating user") | ||||
| 		log.Info().Str("email", email).Str("password", password).Bool("docker", docker).Msg("Creating user") | ||||
|  | ||||
| 		passwordByte, passwordErr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | ||||
|  | ||||
| 		if passwordErr != nil { | ||||
| 			log.Fatal().Err(passwordErr).Msg("Failed to hash password") | ||||
| 			os.Exit(1) | ||||
| 		} | ||||
|  | ||||
| 		passwordString := string(passwordByte) | ||||
| @@ -71,13 +67,13 @@ var CreateCmd = &cobra.Command{ | ||||
| 			passwordString = strings.ReplaceAll(passwordString, "$", "$$") | ||||
| 		} | ||||
|  | ||||
| 		log.Info().Str("user", fmt.Sprintf("%s:%s", username, passwordString)).Msg("User created") | ||||
| 		log.Info().Str("user", fmt.Sprintf("%s:%s", email, passwordString)).Msg("User created") | ||||
| 	}, | ||||
| } | ||||
|  | ||||
| func init() { | ||||
| 	CreateCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively") | ||||
| 	CreateCmd.Flags().BoolVarP(&docker, "docker", "d", false, "Format output for docker") | ||||
| 	CreateCmd.Flags().StringVarP(&username, "username", "u", "", "Username") | ||||
| 	CreateCmd.Flags().StringVarP(&password, "password", "p", "", "Password") | ||||
| 	CreateCmd.Flags().BoolVar(&interactive, "interactive", false, "Create a user interactively") | ||||
| 	CreateCmd.Flags().BoolVar(&docker, "docker", false, "Format output for docker") | ||||
| 	CreateCmd.Flags().StringVar(&email, "email", "", "Email") | ||||
| 	CreateCmd.Flags().StringVar(&password, "password", "", "Password") | ||||
| } | ||||
| @@ -2,7 +2,6 @@ package verify | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"os" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/charmbracelet/huh" | ||||
| @@ -12,7 +11,7 @@ import ( | ||||
| ) | ||||
|  | ||||
| var interactive bool | ||||
| var username string | ||||
| var email string | ||||
| var password string | ||||
| var docker bool | ||||
| var user string | ||||
| @@ -20,20 +19,20 @@ var user string | ||||
| var VerifyCmd = &cobra.Command{ | ||||
| 	Use:   "verify", | ||||
| 	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 email and password.`, | ||||
| 	Run: func(cmd *cobra.Command, args []string) { | ||||
| 		if interactive { | ||||
| 			form := huh.NewForm( | ||||
| 				huh.NewGroup( | ||||
| 					huh.NewInput().Title("User (user:hash)").Value(&user).Validate((func(s string) error { | ||||
| 					huh.NewInput().Title("User (email:hash)").Value(&user).Validate((func(s string) error { | ||||
| 						if s == "" { | ||||
| 							return errors.New("user cannot be empty") | ||||
| 						} | ||||
| 						return nil | ||||
| 					})), | ||||
| 					huh.NewInput().Title("Username").Value(&username).Validate((func(s string) error { | ||||
| 					huh.NewInput().Title("Email").Value(&email).Validate((func(s string) error { | ||||
| 						if s == "" { | ||||
| 							return errors.New("username cannot be empty") | ||||
| 							return errors.New("email cannot be empty") | ||||
| 						} | ||||
| 						return nil | ||||
| 					})), | ||||
| @@ -53,23 +52,19 @@ var VerifyCmd = &cobra.Command{ | ||||
|  | ||||
| 			if formErr != nil { | ||||
| 				log.Fatal().Err(formErr).Msg("Form failed") | ||||
| 				os.Exit(1) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if username == "" || password == "" || user == "" {  | ||||
| 			log.Error().Msg("Username, password and user cannot be empty") | ||||
| 			os.Exit(1) | ||||
| 		if email == "" || password == "" || user == "" { | ||||
| 			log.Fatal().Msg("Email, password and user cannot be empty") | ||||
| 		} | ||||
|  | ||||
|  | ||||
| 		log.Info().Str("user", user).Str("username", username).Str("password", password).Bool("docker", docker).Msg("Verifying user") | ||||
| 		log.Info().Str("user", user).Str("email", email).Str("password", password).Bool("docker", docker).Msg("Verifying user") | ||||
|  | ||||
| 		userSplit := strings.Split(user, ":") | ||||
|  | ||||
| 		if userSplit[1] == "" { | ||||
| 			log.Error().Msg("User is not formatted correctly") | ||||
| 			os.Exit(1) | ||||
| 			log.Fatal().Msg("User is not formatted correctly") | ||||
| 		} | ||||
|  | ||||
| 		if docker { | ||||
| @@ -78,9 +73,8 @@ var VerifyCmd = &cobra.Command{ | ||||
|  | ||||
| 		verifyErr := bcrypt.CompareHashAndPassword([]byte(userSplit[1]), []byte(password)) | ||||
|  | ||||
| 		if verifyErr != nil || username != userSplit[0] { | ||||
| 			log.Error().Msg("Username or password incorrect") | ||||
| 			os.Exit(1) | ||||
| 		if verifyErr != nil || email != userSplit[0] { | ||||
| 			log.Fatal().Msg("Email or password incorrect") | ||||
| 		} else { | ||||
| 			log.Info().Msg("Verification successful") | ||||
| 		} | ||||
| @@ -90,7 +84,7 @@ var VerifyCmd = &cobra.Command{ | ||||
| func init() { | ||||
| 	VerifyCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively") | ||||
| 	VerifyCmd.Flags().BoolVar(&docker, "docker", false, "Is the user formatted for docker?") | ||||
| 	VerifyCmd.Flags().StringVar(&username, "username", "", "Username") | ||||
| 	VerifyCmd.Flags().StringVar(&email, "email", "", "Email") | ||||
| 	VerifyCmd.Flags().StringVar(&password, "password", "", "Password") | ||||
| 	VerifyCmd.Flags().StringVar(&user, "user", "", "Hash (user:hash combination)") | ||||
| } | ||||
							
								
								
									
										1
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								go.mod
									
									
									
									
									
								
							| @@ -72,6 +72,7 @@ require ( | ||||
| 	golang.org/x/arch v0.13.0 // indirect | ||||
| 	golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // 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/sys v0.29.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/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= | ||||
| 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/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= | ||||
| golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import ( | ||||
| 	"tinyauth/internal/assets" | ||||
| 	"tinyauth/internal/auth" | ||||
| 	"tinyauth/internal/hooks" | ||||
| 	"tinyauth/internal/providers" | ||||
| 	"tinyauth/internal/types" | ||||
| 	"tinyauth/internal/utils" | ||||
|  | ||||
| @@ -20,12 +21,12 @@ import ( | ||||
| 	"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{ | ||||
| 		Config:    config, | ||||
| 		Hooks:     hooks, | ||||
| 		Auth:      auth, | ||||
| 		Router: nil, | ||||
| 		Providers: providers, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -34,6 +35,8 @@ type API struct { | ||||
| 	Router    *gin.Engine | ||||
| 	Hooks     *hooks.Hooks | ||||
| 	Auth      *auth.Auth | ||||
| 	Providers *providers.Providers | ||||
| 	Domain    string | ||||
| } | ||||
|  | ||||
| func (api *API) Init() { | ||||
| @@ -45,7 +48,6 @@ func (api *API) Init() { | ||||
|  | ||||
| 	if distErr != nil { | ||||
| 		log.Fatal().Err(distErr).Msg("Failed to get UI assets") | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
|  | ||||
| 	fileServer := http.FileServer(http.FS(dist)) | ||||
| @@ -53,26 +55,21 @@ func (api *API) Init() { | ||||
|  | ||||
| 	domain, domainErr := utils.GetRootURL(api.Config.AppURL) | ||||
|  | ||||
| 	log.Info().Str("domain", domain).Msg("Using domain for cookies") | ||||
|  | ||||
| 	if domainErr != nil { | ||||
| 		log.Fatal().Err(domainErr).Msg("Failed to get domain") | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
|  | ||||
| 	var isSecure bool | ||||
| 	log.Info().Str("domain", domain).Msg("Using domain for cookies") | ||||
|  | ||||
| 	if api.Config.CookieSecure { | ||||
| 		isSecure = true | ||||
| 	} else { | ||||
| 		isSecure = false | ||||
| 	} | ||||
| 	api.Domain = fmt.Sprintf(".%s", domain) | ||||
|  | ||||
| 	store.Options(sessions.Options{ | ||||
| 		Domain: fmt.Sprintf(".%s", domain), | ||||
| 		Domain:   api.Domain, | ||||
| 		Path:     "/", | ||||
| 		HttpOnly: true, | ||||
| 		Secure: isSecure, | ||||
| 		Secure:   api.Config.CookieSecure, | ||||
| 		MaxAge:   api.Config.CookieExpiry, | ||||
| 	}) | ||||
|  | ||||
| 	router.Use(sessions.Sessions("tinyauth", store)) | ||||
| @@ -93,7 +90,16 @@ func (api *API) Init() { | ||||
|  | ||||
| func (api *API) SetupRoutes() { | ||||
| 	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 { | ||||
| 			c.JSON(200, gin.H{ | ||||
| @@ -111,6 +117,7 @@ func (api *API) SetupRoutes() { | ||||
| 		}) | ||||
|  | ||||
| 		if queryErr != nil { | ||||
| 			log.Error().Err(queryErr).Msg("Failed to build query") | ||||
| 			c.JSON(501, gin.H{ | ||||
| 				"status":  501, | ||||
| 				"message": "Internal Server Error", | ||||
| @@ -127,6 +134,7 @@ func (api *API) SetupRoutes() { | ||||
| 		err := c.BindJSON(&login) | ||||
|  | ||||
| 		if err != nil { | ||||
| 			log.Error().Err(err).Msg("Failed to bind JSON") | ||||
| 			c.JSON(400, gin.H{ | ||||
| 				"status":  400, | ||||
| 				"message": "Bad Request", | ||||
| @@ -134,7 +142,7 @@ func (api *API) SetupRoutes() { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		user := api.Auth.GetUser(login.Username) | ||||
| 		user := api.Auth.GetUser(login.Email) | ||||
|  | ||||
| 		if user == nil { | ||||
| 			c.JSON(401, gin.H{ | ||||
| @@ -153,7 +161,7 @@ func (api *API) SetupRoutes() { | ||||
| 		} | ||||
|  | ||||
| 		session := sessions.Default(c) | ||||
| 		session.Set("tinyauth", user.Username) | ||||
| 		session.Set("tinyauth_sid", fmt.Sprintf("email:%s", login.Email)) | ||||
| 		session.Save() | ||||
|  | ||||
| 		c.JSON(200, gin.H{ | ||||
| @@ -164,9 +172,11 @@ func (api *API) SetupRoutes() { | ||||
|  | ||||
| 	api.Router.POST("/api/logout", func(c *gin.Context) { | ||||
| 		session := sessions.Default(c) | ||||
| 		session.Delete("tinyauth") | ||||
| 		session.Delete("tinyauth_sid") | ||||
| 		session.Save() | ||||
|  | ||||
| 		c.SetCookie("tinyauth_redirect_uri", "", -1, "/", api.Domain, api.Config.CookieSecure, true) | ||||
|  | ||||
| 		c.JSON(200, gin.H{ | ||||
| 			"status":  200, | ||||
| 			"message": "Logged out", | ||||
| @@ -174,14 +184,27 @@ func (api *API) SetupRoutes() { | ||||
| 	}) | ||||
|  | ||||
| 	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 { | ||||
| 			c.JSON(200, gin.H{ | ||||
| 				"status":              200, | ||||
| 				"message":             "Unauthenticated", | ||||
| 				"username": "", | ||||
| 				"email":               "", | ||||
| 				"isLoggedIn":          false, | ||||
| 				"oauth":               false, | ||||
| 				"provider":            "", | ||||
| 				"configuredProviders": api.Providers.GetConfiguredProviders(), | ||||
| 				"disableContinue":     api.Config.DisableContinue, | ||||
| 			}) | ||||
| 			return | ||||
| 		} | ||||
| @@ -189,8 +212,12 @@ func (api *API) SetupRoutes() { | ||||
| 		c.JSON(200, gin.H{ | ||||
| 			"status":              200, | ||||
| 			"message":             "Authenticated", | ||||
| 			"username": userContext.Username, | ||||
| 			"isLoggedIn": true, | ||||
| 			"email":               userContext.Email, | ||||
| 			"isLoggedIn":          userContext.IsLoggedIn, | ||||
| 			"oauth":               userContext.OAuth, | ||||
| 			"provider":            userContext.Provider, | ||||
| 			"configuredProviders": api.Providers.GetConfiguredProviders(), | ||||
| 			"disableContinue":     api.Config.DisableContinue, | ||||
| 		}) | ||||
| 	}) | ||||
|  | ||||
| @@ -200,8 +227,119 @@ func (api *API) SetupRoutes() { | ||||
| 			"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() { | ||||
| 	log.Info().Str("address", api.Config.Address).Int("port", api.Config.Port).Msg("Starting server") | ||||
| @@ -231,3 +369,12 @@ func zerolog() gin.HandlerFunc { | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| 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" | ||||
| ) | ||||
|  | ||||
| func NewAuth(userList types.Users) *Auth { | ||||
| func NewAuth(userList types.Users, oauthWhitelist []string) *Auth { | ||||
| 	return &Auth{ | ||||
| 		Users:          userList, | ||||
| 		OAuthWhitelist: oauthWhitelist, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| type Auth struct { | ||||
| 	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 { | ||||
| 		if user.Username == username { | ||||
| 		if user.Email == email { | ||||
| 			return &user | ||||
| 		} | ||||
| 	} | ||||
| @@ -29,3 +31,15 @@ func (auth *Auth) CheckPassword(user types.User, password string) bool { | ||||
| 	hashedPasswordErr := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) | ||||
| 	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 | ||||
|  | ||||
| import ( | ||||
| 	"strings" | ||||
| 	"tinyauth/internal/auth" | ||||
| 	"tinyauth/internal/providers" | ||||
| 	"tinyauth/internal/types" | ||||
|  | ||||
| 	"github.com/gin-contrib/sessions" | ||||
| 	"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{ | ||||
| 		Auth:      auth, | ||||
| 		Providers: providers, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| type Hooks struct { | ||||
| 	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) | ||||
| 	cookie := session.Get("tinyauth") | ||||
| 	sessionCookie := session.Get("tinyauth_sid") | ||||
|  | ||||
| 	if cookie == nil { | ||||
| 	if sessionCookie == nil { | ||||
| 		return types.UserContext{ | ||||
| 			Username: "", | ||||
| 			Email:      "", | ||||
| 			IsLoggedIn: false, | ||||
| 		} | ||||
| 			OAuth:      false, | ||||
| 			Provider:   "", | ||||
| 		}, nil | ||||
| 	} | ||||
|  | ||||
| 	username, ok := cookie.(string) | ||||
| 	data, dataOk := sessionCookie.(string) | ||||
|  | ||||
| 	if !ok { | ||||
| 	if !dataOk { | ||||
| 		return types.UserContext{ | ||||
| 			Username: "", | ||||
| 			Email:      "", | ||||
| 			IsLoggedIn: false, | ||||
| 		} | ||||
| 			OAuth:      false, | ||||
| 			Provider:   "", | ||||
| 		}, nil | ||||
| 	} | ||||
|  | ||||
| 	user := hooks.Auth.GetUser(username) | ||||
| 	split := strings.Split(data, ":") | ||||
|  | ||||
| 	if len(split) != 2 { | ||||
| 		return types.UserContext{ | ||||
| 			Email:      "", | ||||
| 			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{ | ||||
| 			Username: "", | ||||
| 				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{ | ||||
| 		Username: username, | ||||
| 		Email:      email, | ||||
| 		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,16 +1,18 @@ | ||||
| package types | ||||
|  | ||||
| import "tinyauth/internal/oauth" | ||||
|  | ||||
| type LoginQuery struct { | ||||
| 	RedirectURI string `url:"redirect_uri"` | ||||
| } | ||||
|  | ||||
| type LoginRequest struct { | ||||
| 	Username string `json:"username"` | ||||
| 	Email    string `json:"email"` | ||||
| 	Password string `json:"password"` | ||||
| } | ||||
|  | ||||
| type User struct { | ||||
| 	Username string | ||||
| 	Email    string | ||||
| 	Password string | ||||
| } | ||||
|  | ||||
| @@ -24,11 +26,26 @@ type Config struct { | ||||
| 	Users               string `mapstructure:"users"` | ||||
| 	UsersFile           string `mapstructure:"users-file"` | ||||
| 	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 { | ||||
| 	Username string | ||||
| 	Email      string | ||||
| 	IsLoggedIn bool | ||||
| 	OAuth      bool | ||||
| 	Provider   string | ||||
| } | ||||
|  | ||||
| type APIConfig struct { | ||||
| @@ -37,4 +54,34 @@ type APIConfig struct { | ||||
| 	Secret          string | ||||
| 	AppURL          string | ||||
| 	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") | ||||
| 		} | ||||
| 		usersParsed = append(usersParsed, types.User{ | ||||
| 			Username: userSplit[0], | ||||
| 			Email:    userSplit[0], | ||||
| 			Password: userSplit[1], | ||||
| 		}) | ||||
| 	} | ||||
| @@ -59,3 +59,25 @@ func GetUsersFromFile(usersFile string) (string, error) { | ||||
|  | ||||
| 	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 { ContinuePage } from "./pages/continue-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({ | ||||
|   defaultOptions: { | ||||
| @@ -34,6 +36,8 @@ createRoot(document.getElementById("root")!).render( | ||||
|               <Route path="/login" element={<LoginPage />} /> | ||||
|               <Route path="/logout" element={<LogoutPage />} /> | ||||
|               <Route path="/continue" element={<ContinuePage />} /> | ||||
|               <Route path="/unauthorized" element={<UnauthorizedPage />} /> | ||||
|               <Route path="/error" element={<InternalServerError />} /> | ||||
|               <Route path="*" element={<NotFoundPage />} /> | ||||
|             </Routes> | ||||
|           </BrowserRouter> | ||||
|   | ||||
| @@ -9,12 +9,16 @@ export const ContinuePage = () => { | ||||
|   const params = new URLSearchParams(queryString); | ||||
|   const redirectUri = params.get("redirect_uri"); | ||||
|  | ||||
|   const { isLoggedIn } = useUserContext(); | ||||
|   const { isLoggedIn, disableContinue } = useUserContext(); | ||||
|  | ||||
|   if (!isLoggedIn) { | ||||
|     return <Navigate to="/login" />; | ||||
|   } | ||||
|  | ||||
|   if (disableContinue && redirectUri !== "null") { | ||||
|     window.location.replace(redirectUri!); | ||||
|   } | ||||
|  | ||||
|   const redirect = () => { | ||||
|     notifications.show({ | ||||
|       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 { notifications } from "@mantine/notifications"; | ||||
| import { useMutation } from "@tanstack/react-query"; | ||||
| @@ -7,20 +16,23 @@ import { z } from "zod"; | ||||
| import { useUserContext } from "../context/user-context"; | ||||
| import { Navigate } from "react-router"; | ||||
| import { Layout } from "../components/layouts/layout"; | ||||
| import { GoogleIcon } from "../icons/google"; | ||||
| import { GithubIcon } from "../icons/github"; | ||||
| import { OAuthIcon } from "../icons/oauth"; | ||||
|  | ||||
| export const LoginPage = () => { | ||||
|   const queryString = window.location.search; | ||||
|   const params = new URLSearchParams(queryString); | ||||
|   const redirectUri = params.get("redirect_uri"); | ||||
|  | ||||
|   const { isLoggedIn } = useUserContext(); | ||||
|   const { isLoggedIn, configuredProviders } = useUserContext(); | ||||
|  | ||||
|   if (isLoggedIn) { | ||||
|     return <Navigate to="/logout" />; | ||||
|   } | ||||
|  | ||||
|   const schema = z.object({ | ||||
|     username: z.string(), | ||||
|     email: z.string().email(), | ||||
|     password: z.string(), | ||||
|   }); | ||||
|  | ||||
| @@ -29,7 +41,7 @@ export const LoginPage = () => { | ||||
|   const form = useForm({ | ||||
|     mode: "uncontrolled", | ||||
|     initialValues: { | ||||
|       username: "", | ||||
|       email: "", | ||||
|       password: "", | ||||
|     }, | ||||
|     validate: zodResolver(schema), | ||||
| @@ -42,7 +54,7 @@ export const LoginPage = () => { | ||||
|     onError: () => { | ||||
|       notifications.show({ | ||||
|         title: "Failed to login", | ||||
|         message: "Check your username and password", | ||||
|         message: "Check your email and password", | ||||
|         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) => { | ||||
|     loginMutation.mutate(values); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Layout> | ||||
|       <Title ta="center">Welcome back!</Title> | ||||
|       <Paper shadow="md" p={30} mt={30} radius="md" withBorder> | ||||
|       <Title ta="center">Tinyauth</Title> | ||||
|       <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)}> | ||||
|           <TextInput | ||||
|             label="Username" | ||||
|             placeholder="tinyauth" | ||||
|             label="Email" | ||||
|             placeholder="user@example.com" | ||||
|             required | ||||
|             disabled={loginMutation.isLoading} | ||||
|             key={form.key("username")} | ||||
|             {...form.getInputProps("username")} | ||||
|             key={form.key("email")} | ||||
|             {...form.getInputProps("email")} | ||||
|           /> | ||||
|           <PasswordInput | ||||
|             label="Password" | ||||
| @@ -90,7 +184,7 @@ export const LoginPage = () => { | ||||
|             type="submit" | ||||
|             loading={loginMutation.isLoading} | ||||
|           > | ||||
|             Sign in | ||||
|             Login | ||||
|           </Button> | ||||
|         </form> | ||||
|       </Paper> | ||||
|   | ||||
| @@ -5,9 +5,10 @@ import axios from "axios"; | ||||
| import { useUserContext } from "../context/user-context"; | ||||
| import { Navigate } from "react-router"; | ||||
| import { Layout } from "../components/layouts/layout"; | ||||
| import { capitalize } from "../utils/utils"; | ||||
|  | ||||
| export const LogoutPage = () => { | ||||
|   const { isLoggedIn, username } = useUserContext(); | ||||
|   const { isLoggedIn, email, oauth, provider } = useUserContext(); | ||||
|  | ||||
|   if (!isLoggedIn) { | ||||
|     return <Navigate to="/login" />; | ||||
| @@ -43,8 +44,9 @@ export const LogoutPage = () => { | ||||
|           Logout | ||||
|         </Text> | ||||
|         <Text> | ||||
|           You are currently logged in as <Code>{username}</Code>, click the | ||||
|           button below to log out. | ||||
|           You are currently logged in as <Code>{email}</Code> | ||||
|           {oauth && ` using ${capitalize(provider)}`}. Click the button below to | ||||
|           log out. | ||||
|         </Text> | ||||
|         <Button | ||||
|           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({ | ||||
|   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>; | ||||
|   | ||||
							
								
								
									
										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