mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-10-28 12:45:47 +00:00
Compare commits
24 Commits
v0.3.0-bet
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8d2e059a9 | ||
|
|
2c7a3fc801 | ||
|
|
61fffb9708 | ||
|
|
9d2aef163b | ||
|
|
cc480085c5 | ||
|
|
2c7144937a | ||
|
|
c7ec788ce1 | ||
|
|
96a373a794 | ||
|
|
c5a8639822 | ||
|
|
b87cb54d91 | ||
|
|
f61b6dbad4 | ||
|
|
35854f5ce4 | ||
|
|
c59aaa5600 | ||
|
|
085b1492cc | ||
|
|
a19f3589f8 | ||
|
|
e88ec22ce3 | ||
|
|
90f4c3c980 | ||
|
|
f487e25ac5 | ||
|
|
d4eca52b12 | ||
|
|
433e71bd50 | ||
|
|
80d25551e0 | ||
|
|
143b13af2c | ||
|
|
4457d6f525 | ||
|
|
b901744e03 |
@@ -35,10 +35,10 @@ COPY ./cmd ./cmd
|
||||
COPY ./internal ./internal
|
||||
COPY --from=site-builder /site/dist ./internal/assets/dist
|
||||
|
||||
RUN go build
|
||||
RUN CGO_ENABLED=0 go build
|
||||
|
||||
# Runner
|
||||
FROM busybox:1.37-musl AS runner
|
||||
FROM alpine:3.21 AS runner
|
||||
|
||||
WORKDIR /tinyauth
|
||||
|
||||
|
||||
50
README.md
50
README.md
@@ -1,36 +1,46 @@
|
||||
# Tinyauth - The simplest way to protect your apps with a login screen
|
||||
<div align="center">
|
||||
<img alt="Tinyauth" title="Tinyauth" width="256" src="site/public/logo.png">
|
||||
<h1>Tinyauth</h1>
|
||||
<p>The easiest way to secure your apps with a login screen.</p>
|
||||
</div>
|
||||
|
||||
Tinyauth is an extremely simple traefik middleware that adds a login screen to all of your apps that are using the traefik reverse proxy. Tinyauth is configurable through environment variables and it is only 20MB in size.
|
||||
<div align="center">
|
||||
<img alt="License" src="https://img.shields.io/github/license/steveiliop56/tinyauth">
|
||||
<img alt="Release" src="https://img.shields.io/github/v/release/steveiliop56/tinyauth">
|
||||
<img alt="Commit activity" src="https://img.shields.io/github/commit-activity/w/steveiliop56/tinyauth">
|
||||
<img alt="Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/steveiliop56/tinyauth/release.yml">
|
||||
<img alt="Issues" src="https://img.shields.io/github/issues/steveiliop56/tinyauth">
|
||||
</div>
|
||||
|
||||
## Getting started
|
||||
<br />
|
||||
|
||||
Tinyauth is extremely easy to run since it's shipped as a docker container. The guide on how to get started is available on the website [here](https://tinyauth.doesmycode.work/).
|
||||
Tinyauth is a simple authentication middleware that adds simple email/password login to all of your docker apps. It is made for traefik but it can be extended to work with all reverse proxies like caddy and nginx.
|
||||
|
||||
## FAQ
|
||||
> [!WARNING]
|
||||
> Tinyauth is in active development and configuration may change often. Please make sure to carefully read the release notes before updating.
|
||||
|
||||
### Why?
|
||||
> [!NOTE]
|
||||
> Tinyauth is intended for homelab use and it is not made for production use cases. If you are looking for something production ready please use [authentik](https://goauthentik.io).
|
||||
|
||||
Why make this project? Well, we all know that more powerful alternatives like authentik and authelia exist, but when I tried to use them, I felt overwhelmed with all the configration options and environment variables I had to configure in order for them to work. So, I decided to make a small alternative in Go to both test my skills and cover my simple login screen needs.
|
||||
## Getting Started
|
||||
|
||||
### Is this secure?
|
||||
You can easily get started with tinyauth by following the guide on the documentation [here](https://tinyauth.doesmycode.work/docs/getting-started.html). There is also an available docker compose file [here](./docker-compose.example.yml) that has traefik, nginx and tinyauth to demonstrate its capabilities.
|
||||
|
||||
Probably, the sessions are managed with the gin sessions package so it should be very secure. It is definitely not made for production but it could easily serve as a simple login screen to all of your homelab apps.
|
||||
## Documentation
|
||||
|
||||
### Do I need to login every time?
|
||||
|
||||
No, when you login, tinyauth sets a `tinyauth` cookie in your browser that applies to all of the subdomains of your domain.
|
||||
|
||||
## License
|
||||
|
||||
Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
|
||||
You can find documentation and guides on all available configuration of tinyauth [here](https://tinyauth.doesmycode.work).
|
||||
|
||||
## Contributing
|
||||
|
||||
Any contributions to the codebase are welcome! I am not a cybersecurity person so my code may have a security issue, if you find something that could be used to exploit and bypass tinyauth please let me know as soon as possible so I can fix it.
|
||||
All contributions to the codebase are welcome! If you have any recommendations on how to improve security or find a security issue in tinyauth please open an issue or pull request so it can be fixed as soon as possible!
|
||||
|
||||
## License
|
||||
|
||||
Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions. For more information about the license check the [license](./LICENSE) file.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Credits for the logo go to:
|
||||
Credits for the logo of this app go to:
|
||||
|
||||
- Freepik for providing the hat and police badge.
|
||||
- Renee French for making the gopher logo.
|
||||
- **Freepik** for providing the police hat and logo.
|
||||
- **Renee French** for the original gopher logo.
|
||||
|
||||
86
cmd/root.go
86
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"
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
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,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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
43
internal/oauth/oauth.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func NewOAuth(config oauth2.Config) *OAuth {
|
||||
return &OAuth{
|
||||
Config: config,
|
||||
}
|
||||
}
|
||||
|
||||
type OAuth struct {
|
||||
Config oauth2.Config
|
||||
Context context.Context
|
||||
Token *oauth2.Token
|
||||
Verifier string
|
||||
}
|
||||
|
||||
func (oauth *OAuth) Init() {
|
||||
oauth.Context = context.Background()
|
||||
oauth.Verifier = oauth2.GenerateVerifier()
|
||||
}
|
||||
|
||||
func (oauth *OAuth) GetAuthURL() string {
|
||||
return oauth.Config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(oauth.Verifier))
|
||||
}
|
||||
|
||||
func (oauth *OAuth) ExchangeToken(code string) (string, error) {
|
||||
token, err := oauth.Config.Exchange(oauth.Context, code, oauth2.VerifierOption(oauth.Verifier))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
oauth.Token = token
|
||||
return oauth.Token.AccessToken, nil
|
||||
}
|
||||
|
||||
func (oauth *OAuth) GetClient() *http.Client {
|
||||
return oauth.Config.Client(oauth.Context, oauth.Token)
|
||||
}
|
||||
35
internal/providers/generic.go
Normal file
35
internal/providers/generic.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type GenericUserInfoResponse struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func GetGenericEmail(client *http.Client, url string) (string, error) {
|
||||
res, resErr := client.Get(url)
|
||||
|
||||
if resErr != nil {
|
||||
return "", resErr
|
||||
}
|
||||
|
||||
body, bodyErr := io.ReadAll(res.Body)
|
||||
|
||||
if bodyErr != nil {
|
||||
return "", bodyErr
|
||||
}
|
||||
|
||||
var user GenericUserInfoResponse
|
||||
|
||||
jsonErr := json.Unmarshal(body, &user)
|
||||
|
||||
if jsonErr != nil {
|
||||
return "", jsonErr
|
||||
}
|
||||
|
||||
return user.Email, nil
|
||||
}
|
||||
47
internal/providers/github.go
Normal file
47
internal/providers/github.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type GithubUserInfoResponse []struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
|
||||
func GithubScopes() []string {
|
||||
return []string{"user:email"}
|
||||
}
|
||||
|
||||
func GetGithubEmail(client *http.Client) (string, error) {
|
||||
res, resErr := client.Get("https://api.github.com/user/emails")
|
||||
|
||||
if resErr != nil {
|
||||
return "", resErr
|
||||
}
|
||||
|
||||
body, bodyErr := io.ReadAll(res.Body)
|
||||
|
||||
if bodyErr != nil {
|
||||
return "", bodyErr
|
||||
}
|
||||
|
||||
var emails GithubUserInfoResponse
|
||||
|
||||
jsonErr := json.Unmarshal(body, &emails)
|
||||
|
||||
if jsonErr != nil {
|
||||
return "", jsonErr
|
||||
}
|
||||
|
||||
for _, email := range emails {
|
||||
if email.Primary {
|
||||
return email.Email, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("no primary email found")
|
||||
}
|
||||
39
internal/providers/google.go
Normal file
39
internal/providers/google.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type GoogleUserInfoResponse struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func GoogleScopes() []string {
|
||||
return []string{"https://www.googleapis.com/auth/userinfo.email"}
|
||||
}
|
||||
|
||||
func GetGoogleEmail(client *http.Client) (string, error) {
|
||||
res, resErr := client.Get("https://www.googleapis.com/userinfo/v2/me")
|
||||
|
||||
if resErr != nil {
|
||||
return "", resErr
|
||||
}
|
||||
|
||||
body, bodyErr := io.ReadAll(res.Body)
|
||||
|
||||
if bodyErr != nil {
|
||||
return "", bodyErr
|
||||
}
|
||||
|
||||
var user GoogleUserInfoResponse
|
||||
|
||||
jsonErr := json.Unmarshal(body, &user)
|
||||
|
||||
if jsonErr != nil {
|
||||
return "", jsonErr
|
||||
}
|
||||
|
||||
return user.Email, nil
|
||||
}
|
||||
127
internal/providers/providers.go
Normal file
127
internal/providers/providers.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"tinyauth/internal/oauth"
|
||||
"tinyauth/internal/types"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/endpoints"
|
||||
)
|
||||
|
||||
func NewProviders(config types.OAuthConfig) *Providers {
|
||||
return &Providers{
|
||||
Config: config,
|
||||
}
|
||||
}
|
||||
|
||||
type Providers struct {
|
||||
Config types.OAuthConfig
|
||||
Github *oauth.OAuth
|
||||
Google *oauth.OAuth
|
||||
Generic *oauth.OAuth
|
||||
}
|
||||
|
||||
func (providers *Providers) Init() {
|
||||
if providers.Config.GithubClientId != "" && providers.Config.GithubClientSecret != "" {
|
||||
log.Info().Msg("Initializing Github OAuth")
|
||||
providers.Github = oauth.NewOAuth(oauth2.Config{
|
||||
ClientID: providers.Config.GithubClientId,
|
||||
ClientSecret: providers.Config.GithubClientSecret,
|
||||
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/github", providers.Config.AppURL),
|
||||
Scopes: GithubScopes(),
|
||||
Endpoint: endpoints.GitHub,
|
||||
})
|
||||
providers.Github.Init()
|
||||
}
|
||||
if providers.Config.GoogleClientId != "" && providers.Config.GoogleClientSecret != "" {
|
||||
log.Info().Msg("Initializing Google OAuth")
|
||||
providers.Google = oauth.NewOAuth(oauth2.Config{
|
||||
ClientID: providers.Config.GoogleClientId,
|
||||
ClientSecret: providers.Config.GoogleClientSecret,
|
||||
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/google", providers.Config.AppURL),
|
||||
Scopes: GoogleScopes(),
|
||||
Endpoint: endpoints.Google,
|
||||
})
|
||||
providers.Google.Init()
|
||||
}
|
||||
if providers.Config.GenericClientId != "" && providers.Config.GenericClientSecret != "" {
|
||||
log.Info().Msg("Initializing Generic OAuth")
|
||||
providers.Generic = oauth.NewOAuth(oauth2.Config{
|
||||
ClientID: providers.Config.GenericClientId,
|
||||
ClientSecret: providers.Config.GenericClientSecret,
|
||||
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/generic", providers.Config.AppURL),
|
||||
Scopes: providers.Config.GenericScopes,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: providers.Config.GenericAuthURL,
|
||||
TokenURL: providers.Config.GenericTokenURL,
|
||||
},
|
||||
})
|
||||
providers.Generic.Init()
|
||||
}
|
||||
}
|
||||
|
||||
func (providers *Providers) GetProvider(provider string) *oauth.OAuth {
|
||||
switch provider {
|
||||
case "github":
|
||||
return providers.Github
|
||||
case "google":
|
||||
return providers.Google
|
||||
case "generic":
|
||||
return providers.Generic
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (providers *Providers) GetUser(provider string) (string, error) {
|
||||
switch provider {
|
||||
case "github":
|
||||
if providers.Github == nil {
|
||||
return "", nil
|
||||
}
|
||||
client := providers.Github.GetClient()
|
||||
email, emailErr := GetGithubEmail(client)
|
||||
if emailErr != nil {
|
||||
return "", emailErr
|
||||
}
|
||||
return email, nil
|
||||
case "google":
|
||||
if providers.Google == nil {
|
||||
return "", nil
|
||||
}
|
||||
client := providers.Google.GetClient()
|
||||
email, emailErr := GetGoogleEmail(client)
|
||||
if emailErr != nil {
|
||||
return "", emailErr
|
||||
}
|
||||
return email, nil
|
||||
case "generic":
|
||||
if providers.Generic == nil {
|
||||
return "", nil
|
||||
}
|
||||
client := providers.Generic.GetClient()
|
||||
email, emailErr := GetGenericEmail(client, providers.Config.GenericUserURL)
|
||||
if emailErr != nil {
|
||||
return "", emailErr
|
||||
}
|
||||
return email, nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *Providers) GetConfiguredProviders() []string {
|
||||
providers := []string{}
|
||||
if provider.Github != nil {
|
||||
providers = append(providers, "github")
|
||||
}
|
||||
if provider.Google != nil {
|
||||
providers = append(providers, "google")
|
||||
}
|
||||
if provider.Generic != nil {
|
||||
providers = append(providers, "generic")
|
||||
}
|
||||
return providers
|
||||
}
|
||||
@@ -1,40 +1,87 @@
|
||||
package types
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -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, ",")
|
||||
}
|
||||
|
||||
2
main.go
2
main.go
@@ -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
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