Files
tiny-forge/internal/api/auth.go
alexei.dolgolyov 1f81ca9eb0 fix(docker-watcher): address final review findings
Security:
- Move config export behind auth middleware
- Validate OIDC callback token before storing in localStorage
- Use constant-time comparison for webhook secret
- Encrypt OIDC client secret at rest (like registry tokens)

Performance:
- Make TriggerDeploy async from HTTP handlers (return deploy ID
  immediately, run pipeline in background goroutine)

Robustness:
- Wrap api.ts res.json() in try/catch for non-JSON responses

i18n:
- Replace ~20 hardcoded English validation messages with $t() calls
- Localize ConfirmDialog cancel button, InstanceCard confirm titles,
  ProjectCard instance/instances pluralization
- Add validation keys to both en.json and ru.json
2026-03-28 00:14:53 +03:00

332 lines
8.9 KiB
Go

package api
import (
"crypto/rand"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/auth"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/store"
)
// 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
}
respondError(w, http.StatusInternalServerError, "failed to get user: "+err.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 {
respondError(w, http.StatusInternalServerError, "failed to generate token: "+err.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 {
respondError(w, http.StatusInternalServerError, "failed to get user: "+err.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,
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 {
respondError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
return
}
} else {
respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error())
return
}
}
token, err := s.localAuth.GenerateToken(auth.Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to generate token: "+err.Error())
return
}
// Redirect to frontend with token in query parameter.
// The frontend extracts the token and stores it in localStorage.
http.Redirect(w, r, "/?token="+token.Token, http.StatusFound)
}
// getAuthSettings handles GET /api/auth/settings.
func (s *Server) getAuthSettings(w http.ResponseWriter, r *http.Request) {
as, err := s.store.GetAuthSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get auth settings: "+err.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 {
respondError(w, http.StatusInternalServerError, "failed to update auth settings: "+err.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 {
respondError(w, http.StatusInternalServerError, "failed to list users: "+err.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 req.Role == "" {
req.Role = "viewer"
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to hash password: "+err.Error())
return
}
user, err := s.store.CreateUser(store.User{
Username: req.Username,
PasswordHash: hash,
Email: req.Email,
Role: req.Role,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create user: "+err.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 the last admin.
user, err := s.store.GetUserByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "user")
return
}
respondError(w, http.StatusInternalServerError, "failed to get user: "+err.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 {
respondError(w, http.StatusInternalServerError, "failed to delete user: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}