mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-10-29 21:25:43 +00:00
Compare commits
11 Commits
v1.0.0-alp
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47d8f1e5aa | ||
|
|
e8d2e059a9 | ||
|
|
2c7a3fc801 | ||
|
|
61fffb9708 | ||
|
|
9d2aef163b | ||
|
|
cc480085c5 | ||
|
|
2c7144937a | ||
|
|
c7ec788ce1 | ||
|
|
96a373a794 | ||
|
|
c5a8639822 | ||
|
|
b87cb54d91 |
@@ -35,7 +35,7 @@ COPY ./cmd ./cmd
|
|||||||
COPY ./internal ./internal
|
COPY ./internal ./internal
|
||||||
COPY --from=site-builder /site/dist ./internal/assets/dist
|
COPY --from=site-builder /site/dist ./internal/assets/dist
|
||||||
|
|
||||||
RUN go build
|
RUN CGO_ENABLED=0 go build
|
||||||
|
|
||||||
# Runner
|
# Runner
|
||||||
FROM alpine:3.21 AS runner
|
FROM alpine:3.21 AS runner
|
||||||
|
|||||||
50
README.md
50
README.md
@@ -1,36 +1,46 @@
|
|||||||
# Tinyauth - The simplest way to protect your apps with a login screen
|
<div align="center">
|
||||||
|
<img alt="Tinyauth" title="Tinyauth" width="256" src="site/public/logo.png">
|
||||||
|
<h1>Tinyauth</h1>
|
||||||
|
<p>The easiest way to secure your apps with a login screen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
Tinyauth is an extremely simple traefik middleware that adds a login screen to all of your apps that are using the traefik reverse proxy. Tinyauth is configurable through environment variables and it is only 20MB in size.
|
<div align="center">
|
||||||
|
<img alt="License" src="https://img.shields.io/github/license/steveiliop56/tinyauth">
|
||||||
|
<img alt="Release" src="https://img.shields.io/github/v/release/steveiliop56/tinyauth">
|
||||||
|
<img alt="Commit activity" src="https://img.shields.io/github/commit-activity/w/steveiliop56/tinyauth">
|
||||||
|
<img alt="Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/steveiliop56/tinyauth/release.yml">
|
||||||
|
<img alt="Issues" src="https://img.shields.io/github/issues/steveiliop56/tinyauth">
|
||||||
|
</div>
|
||||||
|
|
||||||
## Getting started
|
<br />
|
||||||
|
|
||||||
Tinyauth is extremely easy to run since it's shipped as a docker container. The guide on how to get started is available on the website [here](https://tinyauth.doesmycode.work/).
|
Tinyauth is a simple authentication middleware that adds simple email/password login to all of your docker apps. It is made for traefik but it can be extended to work with all reverse proxies like caddy and nginx.
|
||||||
|
|
||||||
## FAQ
|
> [!WARNING]
|
||||||
|
> Tinyauth is in active development and configuration may change often. Please make sure to carefully read the release notes before updating.
|
||||||
|
|
||||||
### Why?
|
> [!NOTE]
|
||||||
|
> Tinyauth is intended for homelab use and it is not made for production use cases. If you are looking for something production ready please use [authentik](https://goauthentik.io).
|
||||||
|
|
||||||
Why make this project? Well, we all know that more powerful alternatives like authentik and authelia exist, but when I tried to use them, I felt overwhelmed with all the configration options and environment variables I had to configure in order for them to work. So, I decided to make a small alternative in Go to both test my skills and cover my simple login screen needs.
|
## Getting Started
|
||||||
|
|
||||||
### Is this secure?
|
You can easily get started with tinyauth by following the guide on the documentation [here](https://tinyauth.doesmycode.work/docs/getting-started.html). There is also an available docker compose file [here](./docker-compose.example.yml) that has traefik, nginx and tinyauth to demonstrate its capabilities.
|
||||||
|
|
||||||
Probably, the sessions are managed with the gin sessions package so it should be very secure. It is definitely not made for production but it could easily serve as a simple login screen to all of your homelab apps.
|
## Documentation
|
||||||
|
|
||||||
### Do I need to login every time?
|
You can find documentation and guides on all available configuration of tinyauth [here](https://tinyauth.doesmycode.work).
|
||||||
|
|
||||||
No, when you login, tinyauth sets a `tinyauth` cookie in your browser that applies to all of the subdomains of your domain.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Any contributions to the codebase are welcome! I am not a cybersecurity person so my code may have a security issue, if you find something that could be used to exploit and bypass tinyauth please let me know as soon as possible so I can fix it.
|
All contributions to the codebase are welcome! If you have any recommendations on how to improve security or find a security issue in tinyauth please open an issue or pull request so it can be fixed as soon as possible!
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions. For more information about the license check the [license](./LICENSE) file.
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
||||||
Credits for the logo go to:
|
Credits for the logo of this app go to:
|
||||||
|
|
||||||
- Freepik for providing the hat and police badge.
|
- **Freepik** for providing the police hat and logo.
|
||||||
- Renee French for making the gopher logo.
|
- **Renee French** for the original gopher logo.
|
||||||
|
|||||||
22
cmd/root.go
22
cmd/root.go
@@ -56,6 +56,9 @@ var rootCmd = &cobra.Command{
|
|||||||
users, parseErr := utils.ParseUsers(usersString)
|
users, parseErr := utils.ParseUsers(usersString)
|
||||||
HandleError(parseErr, "Failed to parse users")
|
HandleError(parseErr, "Failed to parse users")
|
||||||
|
|
||||||
|
// Create oauth whitelist
|
||||||
|
oauthWhitelist := utils.ParseCommaString(config.OAuthWhitelist)
|
||||||
|
|
||||||
// Create OAuth config
|
// Create OAuth config
|
||||||
oauthConfig := types.OAuthConfig{
|
oauthConfig := types.OAuthConfig{
|
||||||
GithubClientId: config.GithubClientId,
|
GithubClientId: config.GithubClientId,
|
||||||
@@ -64,15 +67,15 @@ var rootCmd = &cobra.Command{
|
|||||||
GoogleClientSecret: config.GoogleClientSecret,
|
GoogleClientSecret: config.GoogleClientSecret,
|
||||||
GenericClientId: config.GenericClientId,
|
GenericClientId: config.GenericClientId,
|
||||||
GenericClientSecret: config.GenericClientSecret,
|
GenericClientSecret: config.GenericClientSecret,
|
||||||
GenericScopes: config.GenericScopes,
|
GenericScopes: utils.ParseCommaString(config.GenericScopes),
|
||||||
GenericAuthURL: config.GenericAuthURL,
|
GenericAuthURL: config.GenericAuthURL,
|
||||||
GenericTokenURL: config.GenericTokenURL,
|
GenericTokenURL: config.GenericTokenURL,
|
||||||
GenericUserInfoURL: config.GenericUserInfoURL,
|
GenericUserURL: config.GenericUserURL,
|
||||||
AppURL: config.AppURL,
|
AppURL: config.AppURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create auth service
|
// Create auth service
|
||||||
auth := auth.NewAuth(users)
|
auth := auth.NewAuth(users, oauthWhitelist)
|
||||||
|
|
||||||
// Create OAuth providers service
|
// Create OAuth providers service
|
||||||
providers := providers.NewProviders(oauthConfig)
|
providers := providers.NewProviders(oauthConfig)
|
||||||
@@ -91,6 +94,7 @@ var rootCmd = &cobra.Command{
|
|||||||
AppURL: config.AppURL,
|
AppURL: config.AppURL,
|
||||||
CookieSecure: config.CookieSecure,
|
CookieSecure: config.CookieSecure,
|
||||||
DisableContinue: config.DisableContinue,
|
DisableContinue: config.DisableContinue,
|
||||||
|
CookieExpiry: config.CookieExpiry,
|
||||||
}, hooks, auth, providers)
|
}, hooks, auth, providers)
|
||||||
|
|
||||||
// Setup routes
|
// Setup routes
|
||||||
@@ -122,8 +126,8 @@ func init() {
|
|||||||
rootCmd.Flags().String("address", "0.0.0.0", "Address to bind the server to.")
|
rootCmd.Flags().String("address", "0.0.0.0", "Address to bind the server to.")
|
||||||
rootCmd.Flags().String("secret", "", "Secret to use for the cookie.")
|
rootCmd.Flags().String("secret", "", "Secret to use for the cookie.")
|
||||||
rootCmd.Flags().String("app-url", "", "The tinyauth URL.")
|
rootCmd.Flags().String("app-url", "", "The tinyauth URL.")
|
||||||
rootCmd.Flags().String("users", "", "Comma separated list of users in the format username:bcrypt-hashed-password.")
|
rootCmd.Flags().String("users", "", "Comma separated list of users in the format email:hash.")
|
||||||
rootCmd.Flags().String("users-file", "", "Path to a file containing users in the format username:bcrypt-hashed-password.")
|
rootCmd.Flags().String("users-file", "", "Path to a file containing users in the format email:hash.")
|
||||||
rootCmd.Flags().Bool("cookie-secure", false, "Send cookie over secure connection only.")
|
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-id", "", "Github OAuth client ID.")
|
||||||
rootCmd.Flags().String("github-client-secret", "", "Github OAuth client secret.")
|
rootCmd.Flags().String("github-client-secret", "", "Github OAuth client secret.")
|
||||||
@@ -134,8 +138,10 @@ func init() {
|
|||||||
rootCmd.Flags().String("generic-scopes", "", "Generic OAuth scopes.")
|
rootCmd.Flags().String("generic-scopes", "", "Generic OAuth scopes.")
|
||||||
rootCmd.Flags().String("generic-auth-url", "", "Generic OAuth auth URL.")
|
rootCmd.Flags().String("generic-auth-url", "", "Generic OAuth auth URL.")
|
||||||
rootCmd.Flags().String("generic-token-url", "", "Generic OAuth token URL.")
|
rootCmd.Flags().String("generic-token-url", "", "Generic OAuth token URL.")
|
||||||
rootCmd.Flags().String("generic-user-info-url", "", "Generic OAuth user info 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().Bool("disable-continue", false, "Disable continue screen and redirect to app directly.")
|
||||||
|
rootCmd.Flags().String("oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth.")
|
||||||
|
rootCmd.Flags().Int("cookie-expiry", 86400, "Cookie expiration time in seconds.")
|
||||||
viper.BindEnv("port", "PORT")
|
viper.BindEnv("port", "PORT")
|
||||||
viper.BindEnv("address", "ADDRESS")
|
viper.BindEnv("address", "ADDRESS")
|
||||||
viper.BindEnv("secret", "SECRET")
|
viper.BindEnv("secret", "SECRET")
|
||||||
@@ -152,7 +158,9 @@ func init() {
|
|||||||
viper.BindEnv("generic-scopes", "GENERIC_SCOPES")
|
viper.BindEnv("generic-scopes", "GENERIC_SCOPES")
|
||||||
viper.BindEnv("generic-auth-url", "GENERIC_AUTH_URL")
|
viper.BindEnv("generic-auth-url", "GENERIC_AUTH_URL")
|
||||||
viper.BindEnv("generic-token-url", "GENERIC_TOKEN_URL")
|
viper.BindEnv("generic-token-url", "GENERIC_TOKEN_URL")
|
||||||
viper.BindEnv("generic-user-info-url", "GENERIC_USER_INFO_URL")
|
viper.BindEnv("generic-user-url", "GENERIC_USER_URL")
|
||||||
viper.BindEnv("disable-continue", "DISABLE_CONTINUE")
|
viper.BindEnv("disable-continue", "DISABLE_CONTINUE")
|
||||||
|
viper.BindEnv("oauth-whitelist", "WHITELIST")
|
||||||
|
viper.BindEnv("cookie-expiry", "COOKIE_EXPIRY")
|
||||||
viper.BindPFlags(rootCmd.Flags())
|
viper.BindPFlags(rootCmd.Flags())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var interactive bool
|
var interactive bool
|
||||||
var username string
|
var email string
|
||||||
var password string
|
var password string
|
||||||
var docker bool
|
var docker bool
|
||||||
|
|
||||||
@@ -24,9 +24,9 @@ var CreateCmd = &cobra.Command{
|
|||||||
if interactive {
|
if interactive {
|
||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewInput().Title("Username").Value(&username).Validate((func(s string) error {
|
huh.NewInput().Title("Email").Value(&email).Validate((func(s string) error {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return errors.New("username cannot be empty")
|
return errors.New("email cannot be empty")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})),
|
})),
|
||||||
@@ -49,11 +49,11 @@ var CreateCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if username == "" || password == "" {
|
if email == "" || password == "" {
|
||||||
log.Error().Msg("Username and password cannot be empty")
|
log.Error().Msg("Email and password cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().Str("username", username).Str("password", password).Bool("docker", docker).Msg("Creating user")
|
log.Info().Str("email", email).Str("password", password).Bool("docker", docker).Msg("Creating user")
|
||||||
|
|
||||||
passwordByte, passwordErr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
passwordByte, passwordErr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
|
||||||
@@ -67,13 +67,13 @@ var CreateCmd = &cobra.Command{
|
|||||||
passwordString = strings.ReplaceAll(passwordString, "$", "$$")
|
passwordString = strings.ReplaceAll(passwordString, "$", "$$")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().Str("user", fmt.Sprintf("%s:%s", username, passwordString)).Msg("User created")
|
log.Info().Str("user", fmt.Sprintf("%s:%s", email, passwordString)).Msg("User created")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
CreateCmd.Flags().BoolVar(&interactive, "interactive", false, "Create a user interactively")
|
CreateCmd.Flags().BoolVar(&interactive, "interactive", false, "Create a user interactively")
|
||||||
CreateCmd.Flags().BoolVar(&docker, "docker", false, "Format output for docker")
|
CreateCmd.Flags().BoolVar(&docker, "docker", false, "Format output for docker")
|
||||||
CreateCmd.Flags().StringVar(&username, "username", "", "Username")
|
CreateCmd.Flags().StringVar(&email, "email", "", "Email")
|
||||||
CreateCmd.Flags().StringVar(&password, "password", "", "Password")
|
CreateCmd.Flags().StringVar(&password, "password", "", "Password")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var interactive bool
|
var interactive bool
|
||||||
var username string
|
var email string
|
||||||
var password string
|
var password string
|
||||||
var docker bool
|
var docker bool
|
||||||
var user string
|
var user string
|
||||||
@@ -19,20 +19,20 @@ var user string
|
|||||||
var VerifyCmd = &cobra.Command{
|
var VerifyCmd = &cobra.Command{
|
||||||
Use: "verify",
|
Use: "verify",
|
||||||
Short: "Verify a user is set up correctly",
|
Short: "Verify a user is set up correctly",
|
||||||
Long: `Verify a user is set up correctly meaning that it has a correct password.`,
|
Long: `Verify a user is set up correctly meaning that it has a correct email and password.`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if interactive {
|
if interactive {
|
||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewInput().Title("User (user:hash)").Value(&user).Validate((func(s string) error {
|
huh.NewInput().Title("User (email:hash)").Value(&user).Validate((func(s string) error {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return errors.New("user cannot be empty")
|
return errors.New("user cannot be empty")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})),
|
})),
|
||||||
huh.NewInput().Title("Username").Value(&username).Validate((func(s string) error {
|
huh.NewInput().Title("Email").Value(&email).Validate((func(s string) error {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return errors.New("username cannot be empty")
|
return errors.New("email cannot be empty")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})),
|
})),
|
||||||
@@ -55,11 +55,11 @@ var VerifyCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if username == "" || password == "" || user == "" {
|
if email == "" || password == "" || user == "" {
|
||||||
log.Fatal().Msg("Username, password and user cannot be empty")
|
log.Fatal().Msg("Email, password and user cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().Str("user", user).Str("username", username).Str("password", password).Bool("docker", docker).Msg("Verifying user")
|
log.Info().Str("user", user).Str("email", email).Str("password", password).Bool("docker", docker).Msg("Verifying user")
|
||||||
|
|
||||||
userSplit := strings.Split(user, ":")
|
userSplit := strings.Split(user, ":")
|
||||||
|
|
||||||
@@ -73,8 +73,8 @@ var VerifyCmd = &cobra.Command{
|
|||||||
|
|
||||||
verifyErr := bcrypt.CompareHashAndPassword([]byte(userSplit[1]), []byte(password))
|
verifyErr := bcrypt.CompareHashAndPassword([]byte(userSplit[1]), []byte(password))
|
||||||
|
|
||||||
if verifyErr != nil || username != userSplit[0] {
|
if verifyErr != nil || email != userSplit[0] {
|
||||||
log.Fatal().Msg("Username or password incorrect")
|
log.Fatal().Msg("Email or password incorrect")
|
||||||
} else {
|
} else {
|
||||||
log.Info().Msg("Verification successful")
|
log.Info().Msg("Verification successful")
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ var VerifyCmd = &cobra.Command{
|
|||||||
func init() {
|
func init() {
|
||||||
VerifyCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively")
|
VerifyCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively")
|
||||||
VerifyCmd.Flags().BoolVar(&docker, "docker", false, "Is the user formatted for docker?")
|
VerifyCmd.Flags().BoolVar(&docker, "docker", false, "Is the user formatted for docker?")
|
||||||
VerifyCmd.Flags().StringVar(&username, "username", "", "Username")
|
VerifyCmd.Flags().StringVar(&email, "email", "", "Email")
|
||||||
VerifyCmd.Flags().StringVar(&password, "password", "", "Password")
|
VerifyCmd.Flags().StringVar(&password, "password", "", "Password")
|
||||||
VerifyCmd.Flags().StringVar(&user, "user", "", "Hash (user:hash combination)")
|
VerifyCmd.Flags().StringVar(&user, "user", "", "Hash (user:hash combination)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,20 +55,12 @@ func (api *API) Init() {
|
|||||||
|
|
||||||
domain, domainErr := utils.GetRootURL(api.Config.AppURL)
|
domain, domainErr := utils.GetRootURL(api.Config.AppURL)
|
||||||
|
|
||||||
log.Info().Str("domain", domain).Msg("Using domain for cookies")
|
|
||||||
|
|
||||||
if domainErr != nil {
|
if domainErr != nil {
|
||||||
log.Fatal().Err(domainErr).Msg("Failed to get domain")
|
log.Fatal().Err(domainErr).Msg("Failed to get domain")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
var isSecure bool
|
log.Info().Str("domain", domain).Msg("Using domain for cookies")
|
||||||
|
|
||||||
if api.Config.CookieSecure {
|
|
||||||
isSecure = true
|
|
||||||
} else {
|
|
||||||
isSecure = false
|
|
||||||
}
|
|
||||||
|
|
||||||
api.Domain = fmt.Sprintf(".%s", domain)
|
api.Domain = fmt.Sprintf(".%s", domain)
|
||||||
|
|
||||||
@@ -76,7 +68,8 @@ func (api *API) Init() {
|
|||||||
Domain: api.Domain,
|
Domain: api.Domain,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: isSecure,
|
Secure: api.Config.CookieSecure,
|
||||||
|
MaxAge: api.Config.CookieExpiry,
|
||||||
})
|
})
|
||||||
|
|
||||||
router.Use(sessions.Sessions("tinyauth", store))
|
router.Use(sessions.Sessions("tinyauth", store))
|
||||||
@@ -279,46 +272,48 @@ func (api *API) SetupRoutes() {
|
|||||||
|
|
||||||
bindErr := c.BindUri(&providerName)
|
bindErr := c.BindUri(&providerName)
|
||||||
|
|
||||||
if bindErr != nil {
|
if handleApiError(c, "Failed to bind URI", bindErr) {
|
||||||
log.Error().Err(bindErr).Msg("Failed to bind URI")
|
|
||||||
c.JSON(400, gin.H{
|
|
||||||
"status": 400,
|
|
||||||
"message": "Bad Request",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
|
|
||||||
if code == "" {
|
if code == "" {
|
||||||
c.JSON(400, gin.H{
|
log.Error().Msg("No code provided")
|
||||||
"status": 400,
|
c.Redirect(http.StatusPermanentRedirect, "/error")
|
||||||
"message": "Bad Request",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
provider := api.Providers.GetProvider(providerName.Provider)
|
provider := api.Providers.GetProvider(providerName.Provider)
|
||||||
|
|
||||||
if provider == nil {
|
if provider == nil {
|
||||||
c.JSON(404, gin.H{
|
c.Redirect(http.StatusPermanentRedirect, "/not-found")
|
||||||
"status": 404,
|
|
||||||
"message": "Not Found",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, tokenErr := provider.ExchangeToken(code)
|
token, tokenErr := provider.ExchangeToken(code)
|
||||||
|
|
||||||
if tokenErr != nil {
|
if handleApiError(c, "Failed to exchange token", tokenErr) {
|
||||||
log.Error().Err(tokenErr).Msg("Failed to exchange token")
|
|
||||||
c.JSON(500, gin.H{
|
|
||||||
"status": 500,
|
|
||||||
"message": "Internal Server Error",
|
|
||||||
})
|
|
||||||
return
|
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 := sessions.Default(c)
|
||||||
session.Set("tinyauth_sid", fmt.Sprintf("%s:%s", providerName.Provider, token))
|
session.Set("tinyauth_sid", fmt.Sprintf("%s:%s", providerName.Provider, token))
|
||||||
session.Save()
|
session.Save()
|
||||||
@@ -334,20 +329,15 @@ func (api *API) SetupRoutes() {
|
|||||||
|
|
||||||
c.SetCookie("tinyauth_redirect_uri", "", -1, "/", api.Domain, api.Config.CookieSecure, true)
|
c.SetCookie("tinyauth_redirect_uri", "", -1, "/", api.Domain, api.Config.CookieSecure, true)
|
||||||
|
|
||||||
queries, queryErr := query.Values(types.LoginQuery{
|
redirectQuery, redirectQueryErr := query.Values(types.LoginQuery{
|
||||||
RedirectURI: redirectURI,
|
RedirectURI: redirectURI,
|
||||||
})
|
})
|
||||||
|
|
||||||
if queryErr != nil {
|
if handleApiError(c, "Failed to build query", redirectQueryErr) {
|
||||||
log.Error().Err(queryErr).Msg("Failed to build query")
|
|
||||||
c.JSON(501, gin.H{
|
|
||||||
"status": 501,
|
|
||||||
"message": "Internal Server Error",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/continue?%s", api.Config.AppURL, queries.Encode()))
|
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/continue?%s", api.Config.AppURL, redirectQuery.Encode()))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,3 +369,12 @@ func zerolog() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleApiError(c *gin.Context, msg string, err error) bool {
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg(msg)
|
||||||
|
c.Redirect(http.StatusPermanentRedirect, "/error")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewAuth(userList types.Users) *Auth {
|
func NewAuth(userList types.Users, oauthWhitelist []string) *Auth {
|
||||||
return &Auth{
|
return &Auth{
|
||||||
Users: userList,
|
Users: userList,
|
||||||
|
OAuthWhitelist: oauthWhitelist,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
Users types.Users
|
Users types.Users
|
||||||
|
OAuthWhitelist []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *Auth) GetUser(email string) *types.User {
|
func (auth *Auth) GetUser(email string) *types.User {
|
||||||
@@ -28,4 +30,16 @@ func (auth *Auth) GetUser(email string) *types.User {
|
|||||||
func (auth *Auth) CheckPassword(user types.User, password string) bool {
|
func (auth *Auth) CheckPassword(user types.User, password string) bool {
|
||||||
hashedPasswordErr := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
|
hashedPasswordErr := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
|
||||||
return hashedPasswordErr == nil
|
return hashedPasswordErr == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (auth *Auth) EmailWhitelisted(emailSrc string) bool {
|
||||||
|
if len(auth.OAuthWhitelist) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, email := range auth.OAuthWhitelist {
|
||||||
|
if email == emailSrc {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,6 +105,17 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) (types.UserContext, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !hooks.Auth.EmailWhitelisted(email) {
|
||||||
|
session.Delete("tinyauth_sid")
|
||||||
|
session.Save()
|
||||||
|
return types.UserContext{
|
||||||
|
Email: "",
|
||||||
|
IsLoggedIn: false,
|
||||||
|
OAuth: false,
|
||||||
|
Provider: "",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
return types.UserContext{
|
return types.UserContext{
|
||||||
Email: email,
|
Email: email,
|
||||||
IsLoggedIn: true,
|
IsLoggedIn: true,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func (providers *Providers) Init() {
|
|||||||
ClientID: providers.Config.GenericClientId,
|
ClientID: providers.Config.GenericClientId,
|
||||||
ClientSecret: providers.Config.GenericClientSecret,
|
ClientSecret: providers.Config.GenericClientSecret,
|
||||||
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/generic", providers.Config.AppURL),
|
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/generic", providers.Config.AppURL),
|
||||||
Scopes: []string{providers.Config.GenericScopes},
|
Scopes: providers.Config.GenericScopes,
|
||||||
Endpoint: oauth2.Endpoint{
|
Endpoint: oauth2.Endpoint{
|
||||||
AuthURL: providers.Config.GenericAuthURL,
|
AuthURL: providers.Config.GenericAuthURL,
|
||||||
TokenURL: providers.Config.GenericTokenURL,
|
TokenURL: providers.Config.GenericTokenURL,
|
||||||
@@ -102,7 +102,7 @@ func (providers *Providers) GetUser(provider string) (string, error) {
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
client := providers.Generic.GetClient()
|
client := providers.Generic.GetClient()
|
||||||
email, emailErr := GetGenericEmail(client, providers.Config.GenericUserInfoURL)
|
email, emailErr := GetGenericEmail(client, providers.Config.GenericUserURL)
|
||||||
if emailErr != nil {
|
if emailErr != nil {
|
||||||
return "", emailErr
|
return "", emailErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ type Config struct {
|
|||||||
GenericScopes string `mapstructure:"generic-scopes"`
|
GenericScopes string `mapstructure:"generic-scopes"`
|
||||||
GenericAuthURL string `mapstructure:"generic-auth-url"`
|
GenericAuthURL string `mapstructure:"generic-auth-url"`
|
||||||
GenericTokenURL string `mapstructure:"generic-token-url"`
|
GenericTokenURL string `mapstructure:"generic-token-url"`
|
||||||
GenericUserInfoURL string `mapstructure:"generic-user-info-url"`
|
GenericUserURL string `mapstructure:"generic-user-info-url"`
|
||||||
DisableContinue bool `mapstructure:"disable-continue"`
|
DisableContinue bool `mapstructure:"disable-continue"`
|
||||||
|
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
|
||||||
|
CookieExpiry int `mapstructure:"cookie-expiry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserContext struct {
|
type UserContext struct {
|
||||||
@@ -52,6 +54,7 @@ type APIConfig struct {
|
|||||||
Secret string
|
Secret string
|
||||||
AppURL string
|
AppURL string
|
||||||
CookieSecure bool
|
CookieSecure bool
|
||||||
|
CookieExpiry int
|
||||||
DisableContinue bool
|
DisableContinue bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,10 +65,10 @@ type OAuthConfig struct {
|
|||||||
GoogleClientSecret string
|
GoogleClientSecret string
|
||||||
GenericClientId string
|
GenericClientId string
|
||||||
GenericClientSecret string
|
GenericClientSecret string
|
||||||
GenericScopes string
|
GenericScopes []string
|
||||||
GenericAuthURL string
|
GenericAuthURL string
|
||||||
GenericTokenURL string
|
GenericTokenURL string
|
||||||
GenericUserInfoURL string
|
GenericUserURL string
|
||||||
AppURL string
|
AppURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,3 +81,7 @@ type OAuthProviders struct {
|
|||||||
Google *oauth.OAuth
|
Google *oauth.OAuth
|
||||||
Microsoft *oauth.OAuth
|
Microsoft *oauth.OAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UnauthorizedQuery struct {
|
||||||
|
Email string `url:"email"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,3 +74,10 @@ func ParseFileToLine(content string) string {
|
|||||||
|
|
||||||
return strings.Join(users, ",")
|
return strings.Join(users, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ParseCommaString(str string) []string {
|
||||||
|
if str == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return strings.Split(str, ",")
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { LoginPage } from "./pages/login-page.tsx";
|
|||||||
import { LogoutPage } from "./pages/logout-page.tsx";
|
import { LogoutPage } from "./pages/logout-page.tsx";
|
||||||
import { ContinuePage } from "./pages/continue-page.tsx";
|
import { ContinuePage } from "./pages/continue-page.tsx";
|
||||||
import { NotFoundPage } from "./pages/not-found-page.tsx";
|
import { NotFoundPage } from "./pages/not-found-page.tsx";
|
||||||
|
import { UnauthorizedPage } from "./pages/unauthorized-page.tsx";
|
||||||
|
import { InternalServerError } from "./pages/internal-server-error.tsx";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -34,6 +36,8 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/logout" element={<LogoutPage />} />
|
<Route path="/logout" element={<LogoutPage />} />
|
||||||
<Route path="/continue" element={<ContinuePage />} />
|
<Route path="/continue" element={<ContinuePage />} />
|
||||||
|
<Route path="/unauthorized" element={<UnauthorizedPage />} />
|
||||||
|
<Route path="/error" element={<InternalServerError />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user