Compare commits

..

9 Commits

Author SHA1 Message Date
Stavros
6596e4dea6 chore: various readme updates 2025-01-20 21:22:03 +02:00
Stavros
a2e6231cc4 chore: bump version 2025-01-20 21:17:49 +02:00
Stavros
644b343a1b chore: add dev docker compose file 2025-01-20 21:16:46 +02:00
Stavros
7817add9b4 chore: update readme 2025-01-20 20:36:00 +02:00
Stavros
fcaa3779d5 feat: allow users config from file 2025-01-20 18:39:22 +02:00
Stavros
e2f97d1fbe refactor: remove root url 2025-01-20 18:22:17 +02:00
Stavros
4f4645f32b refactor: use code block to display the user 2025-01-19 23:01:49 +02:00
Stavros
d0c1aae1e7 refactor: use a hook for checking sign in status in the backend 2025-01-19 23:00:27 +02:00
Stavros
b8a134ed12 fix: don't display continue button when redirect uri is null 2025-01-19 22:44:42 +02:00
12 changed files with 196 additions and 82 deletions

8
.gitignore vendored
View File

@@ -2,4 +2,10 @@
internal/assets/dist
# binaries
tinyauth
tinyauth
# test docker compose
docker-compose.test.yml
# users file
users.txt

View File

@@ -1,38 +1,16 @@
# Tinyauth - The easiest way to secure your traefik apps with a login screen
# Tinyauth - The simplest way to protect your apps with a login screen
Tinyauth is an extremely simple traefik forward auth proxy 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.
## Screenshots
Talk is cheap, here are some screenshots:
| | |
| --------------------------------------- | ----------------------------------------- |
| ![Login](./screenshots/login.png) | ![Logout](./screenshots/logout.png) |
| ![Continue](./screenshots/continue.png) | ![Not Found](./screenshots/not-found.png) |
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.
## Getting started
Tinyauth is extremely easy to run since it's shipped as a docker container. I chose to bundle it with busybox so as you can easily debug the API (e.g. using curl) and have some simple linux tools. If you want to get started with an example just check the example docker compose file [here](./docker-compose.example.yml)
## Environment variables
Tinyauth accepts the following environment variables:
| Name | Description | Default | Required |
| ---------- | ------------------------------------------------------- | ------- | -------- |
| `PORT` | The port the API listens on. | 3000 | no |
| `ADDRESS` | The address the API binds on. | 0.0.0.0 | no |
| `SECRET` | A 32 character long string used for the sessions. | - | yes |
| `ROOT_URL` | The base URL of your domain. (e.g. https://example.com) | - | yes |
| `APP_URL` | The Tinyauth URL. (e.g. https://tinyauth.example.com) | - | yes |
| `USERS` | Comma seperated list of `user:bcrypt-password-hash`. | - | yes |
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/).
## FAQ
### Why?
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.
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.
### Is this secure?
@@ -40,7 +18,7 @@ Probably, the sessions are managed with the gin sessions package so it should be
### 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 the root URL you set.
No, when you login, tinyauth sets a `tinyauth` cookie in your browser that applies to all of the subdomains of your domain.
## License
@@ -48,7 +26,7 @@ Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You ma
## Contributing
Any contributions to the codebase are welcome! I am not a cybersecurity person so my code may have some vulnerability, if you find something that could be used to exploit and bypass tinyauth please tell me as soon as possible so I can fix it.
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.
## Acknowledgements

View File

@@ -2,6 +2,7 @@ package cmd
import (
"os"
"strings"
"time"
"tinyauth/internal/api"
"tinyauth/internal/assets"
@@ -36,10 +37,30 @@ var rootCmd = &cobra.Command{
validateErr := validator.Struct(config)
HandleError(validateErr, "Invalid config")
// Create users list
log.Info().Msg("Creating users list")
userList, createErr := utils.CreateUsersList(config.Users)
HandleError(createErr, "Failed to create users list")
// Parse users
log.Info().Msg("Parsing users")
if config.UsersFile == "" && config.Users == "" {
log.Fatal().Msg("No users provided")
os.Exit(1)
}
users := config.Users
if config.UsersFile != "" {
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"), ",")
if users != "" {
users = users + "," + usersFromFileParsed
} else {
users = usersFromFileParsed
}
}
userList, createErr := utils.ParseUsers(users)
HandleError(createErr, "Failed to parse users")
// Start server
log.Info().Msg("Starting server")
@@ -66,14 +87,14 @@ func init() {
rootCmd.Flags().IntP("port", "p", 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("root-url", "", "Root URL of traefik.")
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.")
viper.BindEnv("port", "PORT")
viper.BindEnv("address", "ADDRESS")
viper.BindEnv("secret", "SECRET")
viper.BindEnv("root-url", "ROOT_URL")
viper.BindEnv("app-url", "APP_URL")
viper.BindEnv("users", "USERS")
viper.BindEnv("users-file", "USERS_FILE")
viper.BindPFlags(rootCmd.Flags())
}

34
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,34 @@
services:
traefik:
container_name: traefik
image: traefik:v3.3
command: --api.insecure=true --providers.docker
ports:
- 80:80
volumes:
- /var/run/docker.sock:/var/run/docker.sock
labels:
traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth:3000/api/auth
nginx:
container_name: nginx
image: nginx:latest
labels:
traefik.enable: true
traefik.http.routers.nginx.rule: Host(`nginx.dev.local`)
traefik.http.services.nginx.loadbalancer.server.port: 80
traefik.http.routers.nginx.middlewares: tinyauth
tinyauth:
container_name: tinyauth
build:
context: .
dockerfile: Dockerfile
environment:
- SECRET=some-random-32-chars-string
- APP_URL=http://tinyauth.dev.local
- USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password
labels:
traefik.enable: true
traefik.http.routers.tinyauth.rule: Host(`tinyauth.dev.local`)
traefik.http.services.tinyauth.loadbalancer.server.port: 3000

View File

@@ -15,7 +15,7 @@ services:
image: nginx:latest
labels:
traefik.enable: true
traefik.http.routers.nginx.rule: Host(`nginx.dev.local`)
traefik.http.routers.nginx.rule: Host(`nginx.example.com`)
traefik.http.services.nginx.loadbalancer.server.port: 80
traefik.http.routers.nginx.middlewares: tinyauth
@@ -24,10 +24,9 @@ services:
image: ghcr.io/steveiliop56/tinyauth:latest
environment:
- SECRET=some-random-32-chars-string
- ROOT_URL=https://example.com
- APP_URL=https://tinyauth.example.com
- USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u
- USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password
labels:
traefik.enable: true
traefik.http.routers.tinyauth.rule: Host(`tinyauth.dev.local`)
traefik.http.routers.tinyauth.rule: Host(`tinyauth.example.com`)
traefik.http.services.tinyauth.loadbalancer.server.port: 3000

View File

@@ -9,7 +9,9 @@ import (
"time"
"tinyauth/internal/assets"
"tinyauth/internal/auth"
"tinyauth/internal/hooks"
"tinyauth/internal/types"
"tinyauth/internal/utils"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
@@ -32,8 +34,13 @@ func Run(config types.Config, users types.UserList) {
fileServer := http.FileServer(http.FS(dist))
store := cookie.NewStore([]byte(config.Secret))
domain := strings.Split(config.RootURL, "://")[1]
domain, domainErr := utils.GetRootURL(config.AppURL)
if domainErr != nil {
log.Fatal().Err(domainErr).Msg("Failed to get domain")
os.Exit(1)
}
store.Options(sessions.Options{
Domain: fmt.Sprintf(".%s", domain),
Path: "/",
@@ -52,20 +59,14 @@ func Run(config types.Config, users types.UserList) {
})
router.GET("/api/auth", func (c *gin.Context) {
session := sessions.Default(c)
value := session.Get("tinyauth")
userContext := hooks.UseUserContext(c, users)
if value != nil {
usernameString, ok := value.(string)
if ok {
if auth.FindUser(users, usernameString) != nil {
c.JSON(200, gin.H{
"status": 200,
"message": "Authorized",
})
return
}
}
if userContext.IsLoggedIn {
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
})
return
}
uri := c.Request.Header.Get("X-Forwarded-Uri")
@@ -139,29 +140,23 @@ func Run(config types.Config, users types.UserList) {
})
router.GET("/api/status", func (c *gin.Context) {
session := sessions.Default(c)
value := session.Get("tinyauth")
userContext := hooks.UseUserContext(c, users)
if value != nil {
usernameString, ok := value.(string)
if ok {
if auth.FindUser(users, usernameString) != nil {
c.JSON(200, gin.H{
"status": 200,
"isLoggedIn": true,
"username": usernameString,
"version": assets.Version,
})
return
}
}
}
if !userContext.IsLoggedIn {
c.JSON(200, gin.H{
"status": 200,
"message": "Unauthenticated",
"username": "",
"isLoggedIn": false,
})
return
}
c.JSON(200, gin.H{
"status": 200,
"isLoggedIn": false,
"username": "",
"version": assets.Version,
"message": "Authenticated",
"username": userContext.Username,
"isLoggedIn": true,
})
})

View File

@@ -1 +1 @@
v0.1.0
v0.2.0

44
internal/hooks/hooks.go Normal file
View File

@@ -0,0 +1,44 @@
package hooks
import (
"tinyauth/internal/auth"
"tinyauth/internal/types"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
func UseUserContext(c *gin.Context, userList types.UserList) (types.UserContext) {
session := sessions.Default(c)
cookie := session.Get("tinyauth")
if cookie == nil {
return types.UserContext{
Username: "",
IsLoggedIn: false,
}
}
username, ok := cookie.(string)
if !ok {
return types.UserContext{
Username: "",
IsLoggedIn: false,
}
}
user := auth.FindUser(userList, username)
if user == nil {
return types.UserContext{
Username: "",
IsLoggedIn: false,
}
}
return types.UserContext{
Username: username,
IsLoggedIn: true,
}
}

View File

@@ -22,7 +22,12 @@ type Config struct {
Port int `validate:"number" mapstructure:"port"`
Address string `mapstructure:"address, ip4_addr"`
Secret string `validate:"required,len=32" mapstructure:"secret"`
RootURL string `validate:"required,url" mapstructure:"root-url"`
AppURL string `validate:"required,url" mapstructure:"app-url"`
Users string `validate:"required" mapstructure:"users"`
Users string `mapstructure:"users"`
UsersFile string `mapstructure:"users-file"`
}
type UserContext struct {
Username string
IsLoggedIn bool
}

View File

@@ -2,16 +2,18 @@ package utils
import (
"errors"
"net/url"
"os"
"strings"
"tinyauth/internal/types"
)
func CreateUsersList(users string) (types.UserList, error) {
func ParseUsers(users string) (types.UserList, error) {
var userList types.UserList
userListString := strings.Split(users, ",")
if len(userListString) == 0 {
return types.UserList{}, errors.New("no users found")
return types.UserList{}, errors.New("invalid user format")
}
for _, user := range userListString {
@@ -26,4 +28,34 @@ func CreateUsersList(users string) (types.UserList, error) {
}
return userList, nil
}
}
func GetRootURL(urlSrc string) (string, error) {
urlParsed, parseErr := url.Parse(urlSrc)
if parseErr != nil {
return "", parseErr
}
urlSplitted := strings.Split(urlParsed.Host, ".")
urlFinal := urlSplitted[len(urlSplitted)-2] + "." + urlSplitted[len(urlSplitted)-1]
return urlFinal, nil
}
func GetUsersFromFile(usersFile string) (string, error) {
_, statErr := os.Stat(usersFile)
if statErr != nil {
return "", statErr
}
data, readErr := os.ReadFile(usersFile)
if readErr != nil {
return "", readErr
}
return string(data), nil
}

View File

@@ -29,7 +29,7 @@ export const ContinuePage = () => {
return (
<Layout>
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
{redirectUri ? (
{redirectUri !== "null" ? (
<>
<Text size="xl" fw={700}>
Continue

View File

@@ -1,4 +1,4 @@
import { Button, Paper, Text } from "@mantine/core";
import { Button, Code, Paper, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
@@ -43,8 +43,8 @@ export const LogoutPage = () => {
Logout
</Text>
<Text>
You are currently logged in as {username}, click the button below to
log out.
You are currently logged in as <Code>{username}</Code>, click the
button below to log out.
</Text>
<Button
fullWidth