Compare commits

...

24 Commits

Author SHA1 Message Date
Stavros
e8d2e059a9 fix: pass cookie expiry to api config 2025-01-25 20:00:07 +02:00
Stavros
2c7a3fc801 refactor: simplify cookie secure logic 2025-01-25 16:29:18 +02:00
Stavros
61fffb9708 chore: disable cgo 2025-01-25 16:16:42 +02:00
Stavros
9d2aef163b chore: rename whitelist to oauth whitelist 2025-01-25 15:32:46 +02:00
Stavros
cc480085c5 feat: custom cookie age 2025-01-25 15:29:17 +02:00
Stavros
2c7144937a chore: update readme 2025-01-25 13:20:09 +02:00
Stavros
c7ec788ce1 fix: split generic scopes string to array 2025-01-25 10:25:20 +02:00
Stavros
96a373a794 feat: internal server error page 2025-01-24 20:31:10 +02:00
Stavros
c5a8639822 feat: oauth email whitelist 2025-01-24 20:17:08 +02:00
Stavros
b87cb54d91 refactor: rename generic user info url to generic user url 2025-01-24 19:41:44 +02:00
Stavros
f61b6dbad4 refactor: log errors 2025-01-24 18:24:20 +02:00
Stavros
35854f5ce4 chore: bump version 2025-01-24 18:17:05 +02:00
Stavros
c59aaa5600 feat: add option to disable continue screen 2025-01-24 18:16:23 +02:00
Stavros
085b1492cc fix: ignore new lines in password file 2025-01-24 17:51:32 +02:00
Stavros
a19f3589f8 Merge pull request #5 from steveiliop56/feat/oauth
feat/oauth
2025-01-24 17:45:55 +02:00
Stavros
e88ec22ce3 fix: fix spacing in logout screen 2025-01-24 17:43:12 +02:00
Stavros
90f4c3c980 feat: generic oauth 2025-01-24 17:13:51 +02:00
Stavros
f487e25ac5 refactor: remove microsoft icon 2025-01-24 16:55:03 +02:00
Stavros
d4eca52b12 feat: google oauth 2025-01-24 16:29:21 +02:00
Stavros
433e71bd50 feat: persist sessions and auto redirect to app 2025-01-24 15:29:46 +02:00
Stavros
80d25551e0 wip 2025-01-23 19:16:35 +02:00
Stavros
143b13af2c refactor: remove short flags 2025-01-22 21:50:01 +02:00
Stavros
4457d6f525 feat: add cookie secure option in the cli 2025-01-22 21:37:59 +02:00
Stavros
b901744e03 fix: use password hash instead of password when verifying 2025-01-22 20:18:03 +02:00
30 changed files with 1075 additions and 187 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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"
@@ -19,7 +18,7 @@ import (
var rootCmd = &cobra.Command{
Use: "tinyauth",
Short: "An extremely simple traefik forward auth proxy.",
Long: `Tinyauth is an extremely simple traefik forward-auth login screen that makes securing your apps easy.`,
Long: `Tinyauth is an extremely simple traefik forward-auth login screen that makes securing your apps easy.`,
Run: func(cmd *cobra.Command, args []string) {
// Get config
log.Info().Msg("Parsing config")
@@ -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,20 +56,46 @@ 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{
Port: config.Port,
Address: config.Address,
Secret: config.Secret,
AppURL: config.AppURL,
CookieSecure: config.CookieSecure,
}, hooks, auth)
Port: config.Port,
Address: config.Address,
Secret: config.Secret,
AppURL: config.AppURL,
CookieSecure: config.CookieSecure,
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().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())
}

View File

@@ -3,7 +3,6 @@ package create
import (
"errors"
"fmt"
"os"
"strings"
"github.com/charmbracelet/huh"
@@ -18,9 +17,9 @@ var password string
var docker bool
var CreateCmd = &cobra.Command{
Use: "create",
Use: "create",
Short: "Create a user",
Long: `Create a user either interactively or by passing flags.`,
Long: `Create a user either interactively or by passing flags.`,
Run: func(cmd *cobra.Command, args []string) {
if interactive {
form := huh.NewForm(
@@ -47,13 +46,11 @@ 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)
}
log.Info().Str("username", username).Str("password", password).Bool("docker", docker).Msg("Creating user")
@@ -62,7 +59,6 @@ var CreateCmd = &cobra.Command{
if passwordErr != nil {
log.Fatal().Err(passwordErr).Msg("Failed to hash password")
os.Exit(1)
}
passwordString := string(passwordByte)
@@ -76,8 +72,8 @@ var CreateCmd = &cobra.Command{
}
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(&username, "username", "", "Username")
CreateCmd.Flags().StringVar(&password, "password", "", "Password")
}

View File

@@ -2,7 +2,6 @@ package verify
import (
"errors"
"os"
"strings"
"github.com/charmbracelet/huh"
@@ -18,9 +17,9 @@ var docker bool
var user string
var VerifyCmd = &cobra.Command{
Use: "verify",
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 password.`,
Run: func(cmd *cobra.Command, args []string) {
if interactive {
form := huh.NewForm(
@@ -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 username == "" || password == "" || user == "" {
log.Fatal().Msg("Username, password and user cannot be empty")
}
log.Info().Str("user", user).Str("username", username).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)
log.Fatal().Msg("Username or password incorrect")
} else {
log.Info().Msg("Verification successful")
}
@@ -93,4 +87,4 @@ func init() {
VerifyCmd.Flags().StringVar(&username, "username", "", "Username")
VerifyCmd.Flags().StringVar(&password, "password", "", "Password")
VerifyCmd.Flags().StringVar(&user, "user", "", "Hash (user:hash combination)")
}
}

1
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -10,6 +10,7 @@ import (
"tinyauth/internal/assets"
"tinyauth/internal/auth"
"tinyauth/internal/hooks"
"tinyauth/internal/providers"
"tinyauth/internal/types"
"tinyauth/internal/utils"
@@ -20,32 +21,33 @@ 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,
Config: config,
Hooks: hooks,
Auth: auth,
Providers: providers,
}
}
type API struct {
Config types.APIConfig
Router *gin.Engine
Hooks *hooks.Hooks
Auth *auth.Auth
Config types.APIConfig
Router *gin.Engine
Hooks *hooks.Hooks
Auth *auth.Auth
Providers *providers.Providers
Domain string
}
func (api *API) Init() {
gin.SetMode(gin.ReleaseMode)
router := gin.New()
router.Use(zerolog())
dist, distErr := fs.Sub(assets.Assets, "dist")
if distErr != nil {
log.Fatal().Err(distErr).Msg("Failed to get UI assets")
os.Exit(1)
}
fileServer := http.FileServer(http.FS(dist))
@@ -53,31 +55,26 @@ 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")
api.Domain = fmt.Sprintf(".%s", domain)
if api.Config.CookieSecure {
isSecure = true
} else {
isSecure = false
}
store.Options(sessions.Options{
Domain: fmt.Sprintf(".%s", domain),
Path: "/",
Domain: api.Domain,
Path: "/",
HttpOnly: true,
Secure: isSecure,
Secure: api.Config.CookieSecure,
MaxAge: api.Config.CookieExpiry,
})
router.Use(sessions.Sessions("tinyauth", store))
router.Use(func(c *gin.Context) {
router.Use(sessions.Sessions("tinyauth", store))
router.Use(func(c *gin.Context) {
if !strings.HasPrefix(c.Request.URL.Path, "/api") {
_, err := fs.Stat(dist, strings.TrimPrefix(c.Request.URL.Path, "/"))
if os.IsNotExist(err) {
@@ -92,12 +89,21 @@ 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{
"status": 200,
"status": 200,
"message": "Authenticated",
})
return
@@ -111,8 +117,9 @@ func (api *API) SetupRoutes() {
})
if queryErr != nil {
log.Error().Err(queryErr).Msg("Failed to build query")
c.JSON(501, gin.H{
"status": 501,
"status": 501,
"message": "Internal Server Error",
})
return
@@ -121,24 +128,25 @@ 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,
"status": 400,
"message": "Bad Request",
})
return
}
user := api.Auth.GetUser(login.Username)
user := api.Auth.GetUser(login.Email)
if user == nil {
c.JSON(401, gin.H{
"status": 401,
"status": 401,
"message": "Unauthorized",
})
return
@@ -146,62 +154,192 @@ func (api *API) SetupRoutes() {
if !api.Auth.CheckPassword(*user, login.Password) {
c.JSON(401, gin.H{
"status": 401,
"status": 401,
"message": "Unauthorized",
})
return
}
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{
"status": 200,
"status": 200,
"message": "Logged in",
})
})
api.Router.POST("/api/logout", func (c *gin.Context) {
api.Router.POST("/api/logout", func(c *gin.Context) {
session := sessions.Default(c)
session.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,
"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": "",
"isLoggedIn": false,
"status": 200,
"message": "Unauthenticated",
"email": "",
"isLoggedIn": false,
"oauth": false,
"provider": "",
"configuredProviders": api.Providers.GetConfiguredProviders(),
"disableContinue": api.Config.DisableContinue,
})
return
}
}
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
"username": userContext.Username,
"isLoggedIn": true,
"status": 200,
"message": "Authenticated",
"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,
"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")
@@ -218,16 +356,25 @@ func zerolog() gin.HandlerFunc {
address := c.Request.RemoteAddr
method := c.Request.Method
path := c.Request.URL.Path
latency := time.Since(tStart).String()
switch {
case code >= 200 && code < 300:
log.Info().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 300 && code < 400:
log.Warn().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 400:
log.Error().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 200 && code < 300:
log.Info().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 300 && code < 400:
log.Warn().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 400:
log.Error().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
}
}
}
}
func handleApiError(c *gin.Context, msg string, err error) bool {
if err != nil {
log.Error().Err(err).Msg(msg)
c.Redirect(http.StatusPermanentRedirect, "/error")
return true
}
return false
}

View File

@@ -1 +1 @@
v0.3.0
v1.0.0

View File

@@ -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,
Users: userList,
OAuthWhitelist: oauthWhitelist,
}
}
type Auth struct {
Users types.Users
Users types.Users
OAuthWhitelist []string
}
func (auth *Auth) GetUser(username string) *types.User {
func (auth *Auth) GetUser(email string) *types.User {
for _, user := range auth.Users {
if user.Username == username {
if user.Email == email {
return &user
}
}
@@ -28,4 +30,16 @@ func (auth *Auth) GetUser(username string) *types.User {
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
}

View File

@@ -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,
Auth: auth,
Providers: providers,
}
}
type Hooks struct {
Auth *auth.Auth
Auth *auth.Auth
Providers *providers.Providers
}
func (hooks *Hooks) UseUserContext(c *gin.Context) (types.UserContext) {
func (hooks *Hooks) UseUserContext(c *gin.Context) (types.UserContext, error) {
session := sessions.Default(c)
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 user == nil {
if len(split) != 2 {
return types.UserContext{
Username: "",
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{
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
View 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)
}

View 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
}

View 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")
}

View 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
}

View 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
}

View File

@@ -1,40 +1,87 @@
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
}
type Users []User
type Config struct {
Port int `validate:"number" mapstructure:"port"`
Address string `mapstructure:"address, ip4_addr"`
Secret string `validate:"required,len=32" mapstructure:"secret"`
AppURL string `validate:"required,url" mapstructure:"app-url"`
Users string `mapstructure:"users"`
UsersFile string `mapstructure:"users-file"`
CookieSecure bool `mapstructure:"cookie-secure"`
Port int `validate:"number" mapstructure:"port"`
Address string `mapstructure:"address, ip4_addr"`
Secret string `validate:"required,len=32" mapstructure:"secret"`
AppURL string `validate:"required,url" mapstructure:"app-url"`
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 {
Port int
Address string
Secret string
AppURL string
CookieSecure bool
}
Port int
Address string
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"`
}

View File

@@ -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, ",")
}

View File

@@ -14,7 +14,7 @@ func main() {
// Logger
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger()
log.Info().Str("version", assets.Version).Msg("Starting tinyauth")
// Run cmd
cmd.Execute()
}

18
site/src/icons/github.tsx Normal file
View 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
View 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
View 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>
);
}

View File

@@ -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>

View File

@@ -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",

View 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>
);
};

View File

@@ -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>

View File

@@ -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

View 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>
);
};

View File

@@ -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
View File

@@ -0,0 +1 @@
export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);