791cd4d6af
Build / build (push) Successful in 12m20s
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.
536 lines
14 KiB
Go
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)
|
|
}
|