Compare commits

...

8 Commits

Author SHA1 Message Date
Stavros
61fffb9708 chore: disable cgo 2025-01-25 16:16:42 +02:00
Stavros
9d2aef163b chore: rename whitelist to oauth whitelist 2025-01-25 15:32:46 +02:00
Stavros
cc480085c5 feat: custom cookie age 2025-01-25 15:29:17 +02:00
Stavros
2c7144937a chore: update readme 2025-01-25 13:20:09 +02:00
Stavros
c7ec788ce1 fix: split generic scopes string to array 2025-01-25 10:25:20 +02:00
Stavros
96a373a794 feat: internal server error page 2025-01-24 20:31:10 +02:00
Stavros
c5a8639822 feat: oauth email whitelist 2025-01-24 20:17:08 +02:00
Stavros
b87cb54d91 refactor: rename generic user info url to generic user url 2025-01-24 19:41:44 +02:00
12 changed files with 192 additions and 63 deletions

View File

@@ -35,7 +35,7 @@ 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 alpine:3.21 AS runner

View File

@@ -1,36 +1,46 @@
# Tinyauth - The simplest way to protect your apps with a login screen
<div align="center">
<img alt="Tinyauth" title="Tinyauth" width="256" src="site/public/logo.png">
<h1>Tinyauth</h1>
<p>The easiest way to secure your apps with a login screen.</p>
</div>
Tinyauth is an extremely simple traefik middleware that adds a login screen to all of your apps that are using the traefik reverse proxy. Tinyauth is configurable through environment variables and it is only 20MB in size.
<div align="center">
<img alt="License" src="https://img.shields.io/github/license/steveiliop56/tinyauth">
<img alt="Release" src="https://img.shields.io/github/v/release/steveiliop56/tinyauth">
<img alt="Commit activity" src="https://img.shields.io/github/commit-activity/w/steveiliop56/tinyauth">
<img alt="Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/steveiliop56/tinyauth/release.yml">
<img alt="Issues" src="https://img.shields.io/github/issues/steveiliop56/tinyauth">
</div>
## Getting started
<br />
Tinyauth is extremely easy to run since it's shipped as a docker container. The guide on how to get started is available on the website [here](https://tinyauth.doesmycode.work/).
Tinyauth is a simple authentication middleware that adds simple email/password login to all of your docker apps. It is made for traefik but it can be extended to work with all reverse proxies like caddy and nginx.
## FAQ
> [!WARNING]
> Tinyauth is in active development and configuration may change often. Please make sure to carefully read the release notes before updating.
### Why?
> [!NOTE]
> Tinyauth is intended for homelab use and it is not made for production use cases. If you are looking for something production ready please use [authentik](https://goauthentik.io).
Why make this project? Well, we all know that more powerful alternatives like authentik and authelia exist, but when I tried to use them, I felt overwhelmed with all the configration options and environment variables I had to configure in order for them to work. So, I decided to make a small alternative in Go to both test my skills and cover my simple login screen needs.
## Getting Started
### Is this secure?
You can easily get started with tinyauth by following the guide on the documentation [here](https://tinyauth.doesmycode.work/docs/getting-started.html). There is also an available docker compose file [here](./docker-compose.example.yml) that has traefik, nginx and tinyauth to demonstrate its capabilities.
Probably, the sessions are managed with the gin sessions package so it should be very secure. It is definitely not made for production but it could easily serve as a simple login screen to all of your homelab apps.
## Documentation
### Do I need to login every time?
No, when you login, tinyauth sets a `tinyauth` cookie in your browser that applies to all of the subdomains of your domain.
## License
Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
You can find documentation and guides on all available configuration of tinyauth [here](https://tinyauth.doesmycode.work).
## Contributing
Any contributions to the codebase are welcome! I am not a cybersecurity person so my code may have a security issue, if you find something that could be used to exploit and bypass tinyauth please let me know as soon as possible so I can fix it.
All contributions to the codebase are welcome! If you have any recommendations on how to improve security or find a security issue in tinyauth please open an issue or pull request so it can be fixed as soon as possible!
## License
Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions. For more information about the license check the [license](./LICENSE) file.
## Acknowledgements
Credits for the logo go to:
Credits for the logo of this app go to:
- Freepik for providing the hat and police badge.
- Renee French for making the gopher logo.
- **Freepik** for providing the police hat and logo.
- **Renee French** for the original gopher logo.

View File

@@ -56,6 +56,9 @@ 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,
@@ -64,15 +67,15 @@ var rootCmd = &cobra.Command{
GoogleClientSecret: config.GoogleClientSecret,
GenericClientId: config.GenericClientId,
GenericClientSecret: config.GenericClientSecret,
GenericScopes: config.GenericScopes,
GenericScopes: utils.ParseCommaString(config.GenericScopes),
GenericAuthURL: config.GenericAuthURL,
GenericTokenURL: config.GenericTokenURL,
GenericUserInfoURL: config.GenericUserInfoURL,
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)
@@ -134,8 +137,10 @@ func init() {
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-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().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")
@@ -152,7 +157,9 @@ func init() {
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-info-url", "GENERIC_USER_INFO_URL")
viper.BindEnv("generic-user-url", "GENERIC_USER_URL")
viper.BindEnv("disable-continue", "DISABLE_CONTINUE")
viper.BindEnv("oauth-whitelist", "WHITELIST")
viper.BindEnv("cookie-expiry", "COOKIE_EXPIRY")
viper.BindPFlags(rootCmd.Flags())
}

View File

@@ -77,6 +77,7 @@ func (api *API) Init() {
Path: "/",
HttpOnly: true,
Secure: isSecure,
MaxAge: api.Config.CookieExpiry,
})
router.Use(sessions.Sessions("tinyauth", store))
@@ -279,46 +280,48 @@ func (api *API) SetupRoutes() {
bindErr := c.BindUri(&providerName)
if bindErr != nil {
log.Error().Err(bindErr).Msg("Failed to bind URI")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
if handleApiError(c, "Failed to bind URI", bindErr) {
return
}
code := c.Query("code")
if code == "" {
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
log.Error().Msg("No code provided")
c.Redirect(http.StatusPermanentRedirect, "/error")
return
}
provider := api.Providers.GetProvider(providerName.Provider)
if provider == nil {
c.JSON(404, gin.H{
"status": 404,
"message": "Not Found",
})
c.Redirect(http.StatusPermanentRedirect, "/not-found")
return
}
token, tokenErr := provider.ExchangeToken(code)
if tokenErr != nil {
log.Error().Err(tokenErr).Msg("Failed to exchange token")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
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()
@@ -334,20 +337,15 @@ func (api *API) SetupRoutes() {
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,
})
if queryErr != nil {
log.Error().Err(queryErr).Msg("Failed to build query")
c.JSON(501, gin.H{
"status": 501,
"message": "Internal Server Error",
})
if handleApiError(c, "Failed to build query", redirectQueryErr) {
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 +377,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
}

View File

@@ -6,14 +6,16 @@ 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(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 {
hashedPasswordErr := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
return hashedPasswordErr == nil
}
}
func (auth *Auth) EmailWhitelisted(emailSrc string) bool {
if len(auth.OAuthWhitelist) == 0 {
return true
}
for _, email := range auth.OAuthWhitelist {
if email == emailSrc {
return true
}
}
return false
}

View File

@@ -105,6 +105,17 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) (types.UserContext, error) {
}, 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{
Email: email,
IsLoggedIn: true,

View File

@@ -52,7 +52,7 @@ func (providers *Providers) Init() {
ClientID: providers.Config.GenericClientId,
ClientSecret: providers.Config.GenericClientSecret,
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/generic", providers.Config.AppURL),
Scopes: []string{providers.Config.GenericScopes},
Scopes: providers.Config.GenericScopes,
Endpoint: oauth2.Endpoint{
AuthURL: providers.Config.GenericAuthURL,
TokenURL: providers.Config.GenericTokenURL,
@@ -102,7 +102,7 @@ func (providers *Providers) GetUser(provider string) (string, error) {
return "", nil
}
client := providers.Generic.GetClient()
email, emailErr := GetGenericEmail(client, providers.Config.GenericUserInfoURL)
email, emailErr := GetGenericEmail(client, providers.Config.GenericUserURL)
if emailErr != nil {
return "", emailErr
}

View File

@@ -35,8 +35,10 @@ type Config struct {
GenericScopes string `mapstructure:"generic-scopes"`
GenericAuthURL string `mapstructure:"generic-auth-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"`
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
CookieExpiry int `mapstructure:"cookie-expiry"`
}
type UserContext struct {
@@ -52,6 +54,7 @@ type APIConfig struct {
Secret string
AppURL string
CookieSecure bool
CookieExpiry int
DisableContinue bool
}
@@ -62,10 +65,10 @@ type OAuthConfig struct {
GoogleClientSecret string
GenericClientId string
GenericClientSecret string
GenericScopes string
GenericScopes []string
GenericAuthURL string
GenericTokenURL string
GenericUserInfoURL string
GenericUserURL string
AppURL string
}
@@ -78,3 +81,7 @@ type OAuthProviders struct {
Google *oauth.OAuth
Microsoft *oauth.OAuth
}
type UnauthorizedQuery struct {
Email string `url:"email"`
}

View File

@@ -74,3 +74,10 @@ func ParseFileToLine(content string) string {
return strings.Join(users, ",")
}
func ParseCommaString(str string) []string {
if str == "" {
return []string{}
}
return strings.Split(str, ",")
}

View File

@@ -13,6 +13,8 @@ import { LoginPage } from "./pages/login-page.tsx";
import { LogoutPage } from "./pages/logout-page.tsx";
import { ContinuePage } from "./pages/continue-page.tsx";
import { NotFoundPage } from "./pages/not-found-page.tsx";
import { UnauthorizedPage } from "./pages/unauthorized-page.tsx";
import { InternalServerError } from "./pages/internal-server-error.tsx";
const queryClient = new QueryClient({
defaultOptions: {
@@ -34,6 +36,8 @@ createRoot(document.getElementById("root")!).render(
<Route path="/login" element={<LoginPage />} />
<Route path="/logout" element={<LogoutPage />} />
<Route path="/continue" element={<ContinuePage />} />
<Route path="/unauthorized" element={<UnauthorizedPage />} />
<Route path="/error" element={<InternalServerError />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>

View File

@@ -0,0 +1,21 @@
import { Button, Paper, Text } from "@mantine/core";
import { Layout } from "../components/layouts/layout";
export const InternalServerError = () => {
return (
<Layout>
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
<Text size="xl" fw={700}>
Internal Server Error
</Text>
<Text>
An error occured on the server and it currently cannot serve your
request.
</Text>
<Button fullWidth mt="xl" onClick={() => window.location.replace("/")}>
Try again
</Button>
</Paper>
</Layout>
);
};

View File

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