Files
tiny-forge/internal/api/auth.go
T
alexei.dolgolyov 791cd4d6af
Build / build (push) Successful in 12m20s
feat: rename Docker Watcher to Tinyforge
Rebrand the project as Tinyforge to reflect its evolution from a Docker
container watcher into a self-hosted mini CI/deployment platform.

Rename covers: Go module path, Docker labels, DB/config filenames,
JWT issuer, Dockerfile binary, docker-compose, CI workflows, frontend
i18n, README with static sites docs, and all code comments.
2026-04-12 21:30:39 +03:00

536 lines
14 KiB
Go

package api
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/store"
)
// rateLimitedLogin wraps the login handler with per-IP rate limiting.
func (s *Server) rateLimitedLogin(rl *rateLimiter) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
ip = fwd
}
if !rl.allow(ip) {
respondError(w, http.StatusTooManyRequests, "too many login attempts, try again later")
return
}
s.login(w, r)
}
}
// authMode handles GET /api/auth/mode — public endpoint returning the auth mode.
func (s *Server) authMode(w http.ResponseWriter, r *http.Request) {
as, err := s.store.GetAuthSettings()
if err != nil {
respondJSON(w, http.StatusOK, map[string]string{"auth_mode": "local"})
return
}
respondJSON(w, http.StatusOK, map[string]string{"auth_mode": as.AuthMode})
}
// login handles POST /api/auth/login.
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
var req auth.LoginRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Username == "" || req.Password == "" {
respondError(w, http.StatusBadRequest, "username and password are required")
return
}
user, err := s.store.GetUserByUsername(req.Username)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondError(w, http.StatusUnauthorized, "invalid credentials")
return
}
slog.Error("failed to get user", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if err := auth.CheckPassword(user.PasswordHash, req.Password); err != nil {
respondError(w, http.StatusUnauthorized, "invalid credentials")
return
}
token, err := s.localAuth.GenerateToken(auth.Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
})
if err != nil {
slog.Error("failed to generate token", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, token)
}
// currentUser handles GET /api/auth/me — returns the authenticated user.
func (s *Server) currentUser(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "not authenticated")
return
}
user, err := s.store.GetUserByID(claims.UserID)
if err != nil {
slog.Error("failed to get user", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, user)
}
// oidcLogin handles GET /api/auth/oidc/login — redirects to OIDC provider.
func (s *Server) oidcLogin(w http.ResponseWriter, r *http.Request) {
if s.oidcProvider == nil {
respondError(w, http.StatusBadRequest, "OIDC is not configured")
return
}
// Generate random state.
stateBytes := make([]byte, 16)
if _, err := rand.Read(stateBytes); err != nil {
respondError(w, http.StatusInternalServerError, "failed to generate state")
return
}
state := hex.EncodeToString(stateBytes)
// Store state in a short-lived cookie for validation on callback.
http.SetCookie(w, &http.Cookie{
Name: "oidc_state",
Value: state,
Path: "/api/auth/oidc",
MaxAge: 300, // 5 minutes
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, s.oidcProvider.AuthCodeURL(state), http.StatusFound)
}
// oidcCallback handles GET /api/auth/oidc/callback — exchanges code for tokens.
func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
if s.oidcProvider == nil {
respondError(w, http.StatusBadRequest, "OIDC is not configured")
return
}
// Validate state parameter.
stateCookie, err := r.Cookie("oidc_state")
if err != nil || stateCookie.Value == "" {
respondError(w, http.StatusBadRequest, "missing OIDC state")
return
}
if r.URL.Query().Get("state") != stateCookie.Value {
respondError(w, http.StatusBadRequest, "invalid OIDC state")
return
}
// Clear the state cookie.
http.SetCookie(w, &http.Cookie{
Name: "oidc_state",
Value: "",
Path: "/api/auth/oidc",
MaxAge: -1,
})
code := r.URL.Query().Get("code")
if code == "" {
respondError(w, http.StatusBadRequest, "missing authorization code")
return
}
userInfo, err := s.oidcProvider.Exchange(r.Context(), code)
if err != nil {
slog.Error("OIDC exchange failed", "error", err)
respondError(w, http.StatusInternalServerError, "OIDC authentication failed")
return
}
// Find or create local user linked to the OIDC identity.
username := userInfo.Username
if username == "" {
username = userInfo.Email
}
if username == "" {
username = userInfo.Subject
}
user, err := s.store.GetUserByUsername(username)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
// Auto-create user from OIDC.
user, err = s.store.CreateUser(store.User{
Username: username,
Email: userInfo.Email,
Role: "viewer", // OIDC users default to viewer; admin promotes via settings
})
if err != nil {
slog.Error("failed to create user", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
} else {
slog.Error("failed to get user", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
}
token, err := s.localAuth.GenerateToken(auth.Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
})
if err != nil {
slog.Error("failed to generate token", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Pass token via short-lived httpOnly cookie. The frontend reads it via
// a dedicated /api/auth/oidc/token endpoint and then the cookie is cleared.
http.SetCookie(w, &http.Cookie{
Name: "auth_token",
Value: token.Token,
Path: "/api/auth/oidc",
MaxAge: 60,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/?oidc=success", http.StatusFound)
}
// oidcExchangeToken handles POST /api/auth/oidc/token — exchanges the httpOnly cookie for a JSON token.
func (s *Server) oidcExchangeToken(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("auth_token")
if err != nil || cookie.Value == "" {
respondError(w, http.StatusUnauthorized, "no OIDC token available")
return
}
// Clear the cookie immediately.
http.SetCookie(w, &http.Cookie{
Name: "auth_token",
Value: "",
Path: "/api/auth/oidc",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
respondJSON(w, http.StatusOK, map[string]string{"token": cookie.Value})
}
// getAuthSettings handles GET /api/auth/settings.
func (s *Server) getAuthSettings(w http.ResponseWriter, r *http.Request) {
as, err := s.store.GetAuthSettings()
if err != nil {
slog.Error("failed to get auth settings", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Mask the client secret for the response.
if as.OIDCClientSecret != "" {
as.OIDCClientSecret = "********"
}
respondJSON(w, http.StatusOK, as)
}
// updateAuthSettings handles PUT /api/auth/settings.
func (s *Server) updateAuthSettings(w http.ResponseWriter, r *http.Request) {
var req store.AuthSettings
if !decodeJSON(w, r, &req) {
return
}
if req.AuthMode != "local" && req.AuthMode != "oidc" {
respondError(w, http.StatusBadRequest, "auth_mode must be 'local' or 'oidc'")
return
}
// If client secret is masked, preserve the existing encrypted value.
if req.OIDCClientSecret == "********" || req.OIDCClientSecret == "" {
existing, err := s.store.GetAuthSettings()
if err == nil {
req.OIDCClientSecret = existing.OIDCClientSecret
}
} else {
// Encrypt the new client secret before storage.
encrypted, err := crypto.Encrypt(s.encKey, req.OIDCClientSecret)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt OIDC client secret")
return
}
// Keep plaintext for OIDC init below, store encrypted.
plaintextSecret := req.OIDCClientSecret
req.OIDCClientSecret = encrypted
defer func() { req.OIDCClientSecret = plaintextSecret }()
}
if err := s.store.UpdateAuthSettings(req); err != nil {
slog.Error("failed to update auth settings", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Re-initialize OIDC provider if mode is oidc and config is present.
if req.AuthMode == "oidc" && req.OIDCIssuerURL != "" && req.OIDCClientID != "" {
s.initOIDCProvider(r.Context(), req)
}
respondJSON(w, http.StatusOK, req)
}
// listUsers handles GET /api/auth/users.
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.store.GetAllUsers()
if err != nil {
slog.Error("failed to list users", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, users)
}
// createUser handles POST /api/auth/users.
func (s *Server) createUser(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
Role string `json:"role"`
}
if !decodeJSON(w, r, &req) {
return
}
if req.Username == "" || req.Password == "" {
respondError(w, http.StatusBadRequest, "username and password are required")
return
}
if err := validatePassword(req.Password); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if req.Role == "" {
req.Role = "viewer"
}
if req.Role != "admin" && req.Role != "viewer" {
respondError(w, http.StatusBadRequest, "role must be 'admin' or 'viewer'")
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
slog.Error("failed to hash password", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
user, err := s.store.CreateUser(store.User{
Username: req.Username,
PasswordHash: hash,
Email: req.Email,
Role: req.Role,
})
if err != nil {
slog.Error("failed to create user", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusCreated, user)
}
// deleteUser handles DELETE /api/auth/users/{uid}.
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "uid")
// Prevent deleting your own account.
claims, ok := auth.ClaimsFromContext(r.Context())
if ok && claims.UserID == id {
respondError(w, http.StatusBadRequest, "cannot delete your own account")
return
}
// Prevent deleting the last admin.
user, err := s.store.GetUserByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "user")
return
}
slog.Error("failed to get user", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if user.Role == "admin" {
users, err := s.store.GetAllUsers()
if err == nil {
adminCount := 0
for _, u := range users {
if u.Role == "admin" {
adminCount++
}
}
if adminCount <= 1 {
respondError(w, http.StatusBadRequest, "cannot delete the last admin user")
return
}
}
}
if err := s.store.DeleteUser(id); err != nil {
slog.Error("failed to delete user", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
// validatePassword checks that a password meets minimum complexity requirements.
func validatePassword(password string) error {
if len(password) < 8 {
return fmt.Errorf("password must be at least 8 characters long")
}
return nil
}
// logout handles POST /api/auth/logout — revokes the current token.
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
tokenStr := auth.ExtractToken(r)
if tokenStr != "" {
s.localAuth.RevokeToken(tokenStr)
}
respondJSON(w, http.StatusOK, map[string]string{"status": "logged out"})
}
// changePassword handles PUT /api/auth/users/{uid}/password.
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
uid := chi.URLParam(r, "uid")
var req struct {
Password string `json:"password"`
}
if !decodeJSON(w, r, &req) {
return
}
if req.Password == "" {
respondError(w, http.StatusBadRequest, "password is required")
return
}
if err := validatePassword(req.Password); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
slog.Error("failed to hash password", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if err := s.store.UpdateUserPassword(uid, hash); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "user")
return
}
slog.Error("failed to update password", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "password updated"})
}
// updateUser handles PUT /api/auth/users/{uid}.
func (s *Server) updateUser(w http.ResponseWriter, r *http.Request) {
uid := chi.URLParam(r, "uid")
var req struct {
Email string `json:"email"`
Role string `json:"role"`
}
if !decodeJSON(w, r, &req) {
return
}
if req.Role != "" && req.Role != "admin" && req.Role != "viewer" {
respondError(w, http.StatusBadRequest, "role must be 'admin' or 'viewer'")
return
}
existing, err := s.store.GetUserByID(uid)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "user")
return
}
slog.Error("failed to get user", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// If demoting from admin, check we're not removing the last admin.
if existing.Role == "admin" && req.Role == "viewer" {
users, err := s.store.GetAllUsers()
if err == nil {
adminCount := 0
for _, u := range users {
if u.Role == "admin" {
adminCount++
}
}
if adminCount <= 1 {
respondError(w, http.StatusBadRequest, "cannot demote the last admin user")
return
}
}
}
if req.Email != "" {
existing.Email = req.Email
}
if req.Role != "" {
existing.Role = req.Role
}
if err := s.store.UpdateUser(existing); err != nil {
slog.Error("failed to update user", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, existing)
}