mirror of
				https://github.com/steveiliop56/tinyauth.git
				synced 2025-11-04 08:05:42 +00:00 
			
		
		
		
	Compare commits
	
		
			26 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 | ||
| 
						 | 
					b901744e03 | ||
| 
						 | 
					61a7400cf1 | 
@@ -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
 | 
			
		||||
 | 
			
		||||
@@ -46,4 +46,4 @@ COPY --from=builder /tinyauth/tinyauth ./
 | 
			
		||||
 | 
			
		||||
EXPOSE 3000
 | 
			
		||||
 | 
			
		||||
CMD ["./tinyauth"]
 | 
			
		||||
ENTRYPOINT ["./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,34 +52,29 @@ 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 {
 | 
			
		||||
			userSplit[1] = strings.ReplaceAll(password, "$$", "$")
 | 
			
		||||
			userSplit[1] = strings.ReplaceAll(userSplit[1], "$$", "$")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		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))
 | 
			
		||||
@@ -92,8 +89,17 @@ func (api *API) Init() {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (api *API) SetupRoutes() {
 | 
			
		||||
	api.Router.GET("/api/auth", func (c *gin.Context) {
 | 
			
		||||
		userContext := api.Hooks.UseUserContext(c)
 | 
			
		||||
	api.Router.GET("/api/auth", func(c *gin.Context) {
 | 
			
		||||
		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",
 | 
			
		||||
@@ -121,12 +128,13 @@ func (api *API) SetupRoutes() {
 | 
			
		||||
		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
 | 
			
		||||
 | 
			
		||||
		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{
 | 
			
		||||
@@ -162,26 +170,41 @@ func (api *API) SetupRoutes() {
 | 
			
		||||
		})
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	api.Router.POST("/api/logout", func (c *gin.Context) {
 | 
			
		||||
	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",
 | 
			
		||||
		})
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	api.Router.GET("/api/status", func (c *gin.Context) {
 | 
			
		||||
		userContext := api.Hooks.UseUserContext(c)
 | 
			
		||||
	api.Router.GET("/api/status", func(c *gin.Context) {
 | 
			
		||||
		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,19 +212,134 @@ 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,
 | 
			
		||||
		})
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	api.Router.GET("/api/healthcheck", func (c *gin.Context) {
 | 
			
		||||
	api.Router.GET("/api/healthcheck", func(c *gin.Context) {
 | 
			
		||||
		c.JSON(200, gin.H{
 | 
			
		||||
			"status":  200,
 | 
			
		||||
			"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