This commit is contained in:
Stavros
2025-03-15 17:06:06 +02:00
parent 52f189563b
commit 07b57fb0ca
14 changed files with 590 additions and 273 deletions

View File

@@ -10,10 +10,12 @@ import (
"time"
"tinyauth/internal/assets"
"tinyauth/internal/auth"
"tinyauth/internal/handlers"
"tinyauth/internal/hooks"
"tinyauth/internal/providers"
"tinyauth/internal/types"
"tinyauth/internal/utils"
docs "tinyauth/docs"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
@@ -21,14 +23,17 @@ import (
"github.com/google/go-querystring/query"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog/log"
swaggerfiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
func NewAPI(config types.APIConfig, hooks *hooks.Hooks, auth *auth.Auth, providers *providers.Providers) *API {
func NewAPI(config types.APIConfig, hooks *hooks.Hooks, auth *auth.Auth, providers *providers.Providers, handlers *handlers.Handlers) *API {
return &API{
Config: config,
Hooks: hooks,
Auth: auth,
Providers: providers,
Handlers: handlers,
}
}
@@ -38,9 +43,15 @@ type API struct {
Hooks *hooks.Hooks
Auth *auth.Auth
Providers *providers.Providers
Handlers *handlers.Handlers
Domain string
}
// @title Tinyauth API
// @version 1.0
// @description Documentation for the Tinyauth API
// @BasePath /api
func (api *API) Init() {
// Disable gin logs
gin.SetMode(gin.ReleaseMode)
@@ -49,6 +60,7 @@ func (api *API) Init() {
log.Debug().Msg("Setting up router")
router := gin.New()
router.Use(zerolog())
router.RedirectTrailingSlash = true
// Read UI assets
log.Debug().Msg("Setting up assets")
@@ -66,19 +78,6 @@ func (api *API) Init() {
log.Debug().Msg("Setting up cookie store")
store := cookie.NewStore([]byte(api.Config.Secret))
// Get domain to use for session cookies
log.Debug().Msg("Getting domain")
domain, domainErr := utils.GetRootURL(api.Config.AppURL)
if domainErr != nil {
log.Fatal().Err(domainErr).Msg("Failed to get domain")
os.Exit(1)
}
log.Info().Str("domain", domain).Msg("Using domain for cookies")
api.Domain = fmt.Sprintf(".%s", domain)
// Use session middleware
store.Options(sessions.Options{
Domain: api.Domain,
@@ -90,6 +89,15 @@ func (api *API) Init() {
router.Use(sessions.Sessions("tinyauth", store))
// Configure swagger
docs.SwaggerInfo.BasePath = "/api"
// Swagger middleware
router.GET("/api/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler))
router.GET("/api/swagger", func(ctx *gin.Context) {
ctx.Redirect(http.StatusPermanentRedirect, "/api/swagger/index.html")
})
// UI middleware
router.Use(func(c *gin.Context) {
// If not an API request, serve the UI
@@ -114,179 +122,9 @@ func (api *API) Init() {
}
func (api *API) SetupRoutes() {
api.Router.GET("/api/auth/:proxy", func(c *gin.Context) {
// Create struct for proxy
var proxy types.Proxy
// Bind URI
bindErr := c.BindUri(&proxy)
// Handle error
if bindErr != nil {
log.Error().Err(bindErr).Msg("Failed to bind URI")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
// Check if the request is coming from a browser (tools like curl/bruno use */* and they don't include the text/html)
isBrowser := strings.Contains(c.Request.Header.Get("Accept"), "text/html")
if isBrowser {
log.Debug().Msg("Request is most likely coming from a browser")
} else {
log.Debug().Msg("Request is most likely not coming from a browser")
}
log.Debug().Interface("proxy", proxy.Proxy).Msg("Got proxy")
// Check if auth is enabled
authEnabled, authEnabledErr := api.Auth.AuthEnabled(c)
// Handle error
if authEnabledErr != nil {
// Return 500 if nginx is the proxy or if the request is not coming from a browser
if proxy.Proxy == "nginx" || !isBrowser {
log.Error().Err(authEnabledErr).Msg("Failed to check if auth is enabled")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
// Return the internal server error page
if api.handleError(c, "Failed to check if auth is enabled", authEnabledErr) {
return
}
}
// If auth is not enabled, return 200
if !authEnabled {
// The user is allowed to access the app
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
})
// Stop further processing
return
}
// Get user context
userContext := api.Hooks.UseUserContext(c)
// Get headers
uri := c.Request.Header.Get("X-Forwarded-Uri")
proto := c.Request.Header.Get("X-Forwarded-Proto")
host := c.Request.Header.Get("X-Forwarded-Host")
// Check if user is logged in
if userContext.IsLoggedIn {
log.Debug().Msg("Authenticated")
// Check if user is allowed to access subdomain, if request is nginx.example.com the subdomain (resource) is nginx
appAllowed, appAllowedErr := api.Auth.ResourceAllowed(c, userContext)
// Check if there was an error
if appAllowedErr != nil {
// Return 500 if nginx is the proxy or if the request is not coming from a browser
if proxy.Proxy == "nginx" || !isBrowser {
log.Error().Err(appAllowedErr).Msg("Failed to check if app is allowed")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
// Return the internal server error page
if api.handleError(c, "Failed to check if app is allowed", appAllowedErr) {
return
}
}
log.Debug().Bool("appAllowed", appAllowed).Msg("Checking if app is allowed")
// The user is not allowed to access the app
if !appAllowed {
log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User not allowed")
// Set WWW-Authenticate header
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
// Return 401 if nginx is the proxy or if the request is not coming from a browser
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Build query
queries, queryErr := query.Values(types.UnauthorizedQuery{
Username: userContext.Username,
Resource: strings.Split(host, ".")[0],
})
// Handle error (no need to check for nginx/headers since we are sure we are using caddy/traefik)
if api.handleError(c, "Failed to build query", queryErr) {
return
}
// We are using caddy/traefik so redirect
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", api.Config.AppURL, queries.Encode()))
// Stop further processing
return
}
// Set the user header
c.Header("Remote-User", userContext.Username)
// The user is allowed to access the app
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
})
// Stop further processing
return
}
// The user is not logged in
log.Debug().Msg("Unauthorized")
// Set www-authenticate header
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
// Return 401 if nginx is the proxy or if the request is not coming from a browser
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Build query
queries, queryErr := query.Values(types.LoginQuery{
RedirectURI: fmt.Sprintf("%s://%s%s", proto, host, uri),
})
// Handle error (no need to check for nginx/headers since we are sure we are using caddy/traefik)
if api.handleError(c, "Failed to build query", queryErr) {
return
}
log.Debug().Interface("redirect_uri", fmt.Sprintf("%s://%s%s", proto, host, uri)).Msg("Redirecting to login")
// Redirect to login
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/?%s", api.Config.AppURL, queries.Encode()))
})
api.Router.GET("/api/healthcheck", api.Handlers.HealthCheck)
api.Router.GET("/api/auth/logout", api.Handlers.Logout)
api.Router.GET("/api/auth", api.Handlers.CheckAuth)
api.Router.POST("/api/login", func(c *gin.Context) {
// Create login struct
@@ -443,24 +281,6 @@ func (api *API) SetupRoutes() {
})
})
api.Router.POST("/api/logout", func(c *gin.Context) {
log.Debug().Msg("Logging out")
// Delete session cookie
api.Auth.DeleteSessionCookie(c)
log.Debug().Msg("Cleaning up redirect cookie")
// Clean up redirect cookie if it exists
c.SetCookie("tinyauth_redirect_uri", "", -1, "/", api.Domain, api.Config.CookieSecure, true)
// Return logged out
c.JSON(200, gin.H{
"status": 200,
"message": "Logged out",
})
})
api.Router.GET("/api/app", func(c *gin.Context) {
log.Debug().Msg("Getting app context")
@@ -708,14 +528,6 @@ func (api *API) SetupRoutes() {
// Redirect to continue with the redirect URI
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/continue?%s", api.Config.AppURL, redirectQuery.Encode()))
})
// Simple healthcheck
api.Router.GET("/api/healthcheck", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": 200,
"message": "OK",
})
})
}
func (api *API) Run() {

View File

@@ -10,6 +10,7 @@ import (
"tinyauth/internal/api"
"tinyauth/internal/auth"
"tinyauth/internal/docker"
"tinyauth/internal/handlers"
"tinyauth/internal/hooks"
"tinyauth/internal/providers"
"tinyauth/internal/types"
@@ -67,8 +68,11 @@ func getAPI(t *testing.T) *api.API {
// Create hooks service
hooks := hooks.NewHooks(auth, providers)
// Create handlers
apiHandlers := handlers.NewHandlers(apiConfig)
// Create API
api := api.NewAPI(apiConfig, hooks, auth, providers)
api := api.NewAPI(apiConfig, hooks, auth, providers, apiHandlers)
// Setup routes
api.Init()

View File

@@ -0,0 +1,209 @@
package handlers
import (
"fmt"
"net/http"
"strings"
"tinyauth/internal/auth"
"tinyauth/internal/hooks"
"tinyauth/internal/types"
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"github.com/rs/zerolog/log"
)
func NewHandlers(config types.APIConfig, auth *auth.Auth, hooks *hooks.Hooks) *Handlers {
return &Handlers{
Config: config,
Auth: auth,
Hooks: hooks,
}
}
type Handlers struct {
Config types.APIConfig
Auth *auth.Auth
Hooks *hooks.Hooks
}
// @Summary Health Check
// @Description Simple health check
// @Tags health
// @Produce json
// @Success 200 {object} types.HealthCheckResponse
// @Router /healthcheck [get]
func (h *Handlers) HealthCheck(c *gin.Context) {
c.JSON(200, gin.H{
"status": 200,
"message": "OK",
})
}
// @Summary Logout
// @Description Log the user out by invalidating the session cookie
// @Tags auth
// @Produce json
// @Success 200 {object} types.LogoutResponse
// @Router /auth/logout [get]
func (h *Handlers) Logout(c *gin.Context) {
log.Debug().Msg("Logging out")
h.Auth.DeleteSessionCookie(c)
log.Debug().Msg("Cleaning up redirect cookie")
c.SetCookie("tinyauth_redirect_uri", "", -1, "/", h.Config.Domain, h.Config.CookieSecure, true)
c.JSON(200, gin.H{
"status": 200,
"message": "Logged out",
})
}
// @Summary Auth Check (Traefik)
// @Description Check the authentication status of the user and redirect to the login page if not authenticated
// @Tags authn
// @Produce json
// @Success 302
// @Router /api/auth/traefik [get]
func (h *Handlers) CheckAuth(c *gin.Context) {
var proxy types.Proxy
err := c.BindUri(&proxy)
if err != nil {
log.Error().Err(err).Msg("Failed to bind URI")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
isBrowser := strings.Contains(c.Request.Header.Get("Accept"), "text/html")
if isBrowser {
log.Debug().Msg("Request is most likely coming from a browser")
} else {
log.Debug().Msg("Request is most likely not coming from a browser")
}
log.Debug().Interface("proxy", proxy.Proxy).Msg("Got proxy")
authEnabled, err := h.Auth.AuthEnabled(c)
if err != nil {
log.Error().Err(err).Msg("Failed to check if auth is enabled")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
if !authEnabled {
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
})
return
}
userContext := h.Hooks.UseUserContext(c)
uri := c.Request.Header.Get("X-Forwarded-Uri")
proto := c.Request.Header.Get("X-Forwarded-Proto")
host := c.Request.Header.Get("X-Forwarded-Host")
if userContext.IsLoggedIn {
log.Debug().Msg("Authenticated")
appAllowed, err := h.Auth.ResourceAllowed(c, userContext)
if err != nil {
log.Error().Err(err).Msg("Failed to check if app is allowed")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
log.Debug().Bool("appAllowed", appAllowed).Msg("Checking if app is allowed")
if !appAllowed {
log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User not allowed")
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
queries, err := query.Values(types.UnauthorizedQuery{
Username: userContext.Username,
Resource: strings.Split(host, ".")[0],
})
if err != nil {
log.Error().Err(err).Msg("Failed to build query")
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode()))
return
}
c.Header("Remote-User", userContext.Username)
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
})
return
}
log.Debug().Msg("Unauthorized")
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
queries, err := query.Values(types.LoginQuery{
RedirectURI: fmt.Sprintf("%s://%s%s", proto, host, uri),
})
if err != nil {
log.Error().Err(err).Msg("Failed to build query")
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
log.Debug().Interface("redirect_uri", fmt.Sprintf("%s://%s%s", proto, host, uri)).Msg("Redirecting to login")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/?%s", h.Config.AppURL, queries.Encode()))
}

15
internal/types/config.go Normal file
View File

@@ -0,0 +1,15 @@
package types
// API config is the configuration for the API
type APIConfig struct {
Port int
Address string
Secret string
AppURL string
CookieSecure bool
SessionExpiry int
DisableContinue bool
GenericName string
Title string
Domain string
}

View File

@@ -0,0 +1,13 @@
package types
// HealthCheckResponse is the response for the health check endpoint
type HealthCheckResponse struct {
Status int `json:"status" example:"200"`
Message string `json:"message" example:"Ok"`
}
// LogoutResponse is the response for the health check endpoint
type LogoutResponse struct {
Status int `json:"status" example:"200"`
Message string `json:"message" example:"Logged out"`
}

View File

@@ -67,19 +67,6 @@ type UserContext struct {
TotpPending bool
}
// APIConfig is the configuration for the API
type APIConfig struct {
Port int
Address string
Secret string
AppURL string
CookieSecure bool
SessionExpiry int
DisableContinue bool
GenericName string
Title string
}
// OAuthConfig is the configuration for the providers
type OAuthConfig struct {
GithubClientId string