Files
DriverVault/API Server/internal/api/auth.go
T
2026-07-06 08:50:52 +02:00

273 lines
7.6 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"carcontrol/api/internal/auth"
)
// tokenTTL is how long an issued login token stays valid.
const tokenTTL = 7 * 24 * time.Hour
// publicPaths bypass authentication. Everything else requires a valid token.
var publicPaths = map[string]bool{
"/api/health": true,
"/api/auth/login": true,
}
type ctxKey string
const claimsKey ctxKey = "claims"
// withAuth rejects requests to non-public paths that lack a valid bearer token.
func (s *Server) withAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Public API paths, plus everything outside /api/ (the embedded web
// panel and its static assets), bypass authentication.
if publicPaths[r.URL.Path] || !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "missing bearer token")
return
}
claims, err := auth.Verify(s.authSecret, token)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
if s.sessionRevoked(r.Context(), claims.Jti) {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// sessionRevoked reports whether the token's backing session is missing or
// revoked (e.g. via "log out this device" from the settings panel). Tokens
// minted before session-tracking existed have no jti and are rejected too,
// which simply forces one fresh login.
func (s *Server) sessionRevoked(ctx context.Context, jti string) bool {
if jti == "" {
return true
}
res, err := s.pb.List(ctx, colSessions, url.Values{
"filter": {fmt.Sprintf("jti='%s'", jti)},
"perPage": {"1"},
})
if err != nil {
return true
}
var recs []sessionRecord
if err := json.Unmarshal(res.Items, &recs); err != nil || len(recs) == 0 {
return true
}
return recs[0].Revoked
}
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
}
// Accept both "Bearer <token>" and a raw token.
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(after)
}
return strings.TrimSpace(h)
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type userInfo struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role,omitempty"`
}
type loginResponse struct {
Token string `json:"token"`
User userInfo `json:"user"`
}
// handleLogin verifies credentials against the PocketBase users collection and,
// on success, mints an API Server JWT.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var in loginRequest
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if in.Email == "" || in.Password == "" {
writeError(w, http.StatusBadRequest, "email and password are required")
return
}
rec, err := s.pb.AuthWithPassword(r.Context(), s.usersCollection, in.Email, in.Password)
if err != nil {
// Don't leak whether it was the email or the password.
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
// The auth record doesn't carry the role field; fetch it so the token and
// login response reflect the user's access role. Default to "user".
role := "user"
if u, err := s.fetchUser(r, rec.ID); err == nil {
role = orDefault(u.Role, "user")
}
jti, err := auth.NewJTI()
if err != nil {
writeError(w, http.StatusInternalServerError, "could not issue token")
return
}
if err := s.createSession(r.Context(), rec.ID, jti, r); err != nil {
writeError(w, http.StatusInternalServerError, "could not create session")
return
}
token, err := auth.Sign(s.authSecret, auth.Claims{
Sub: rec.ID,
Email: rec.Email,
Name: rec.Name,
Role: role,
Jti: jti,
}, tokenTTL)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not issue token")
return
}
writeJSON(w, http.StatusOK, loginResponse{
Token: token,
User: userInfo{ID: rec.ID, Email: rec.Email, Name: rec.Name, Role: role},
})
}
// sessionRecord is one row of the "sessions" collection — a login token's
// device/revocation record, used to power "active sessions" in the settings
// panel and to let a user log a device out remotely.
type sessionRecord struct {
ID string `json:"id"`
User string `json:"user"`
Jti string `json:"jti"`
DeviceLabel string `json:"device_label"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Revoked bool `json:"revoked"`
ExpiresAt string `json:"expires_at"`
Created string `json:"created"`
Updated string `json:"updated"`
}
func (s *Server) createSession(ctx context.Context, userID, jti string, r *http.Request) error {
payload := map[string]any{
"user": userID,
"jti": jti,
"device_label": deviceLabelFromUA(r.UserAgent()),
"ip": clientIP(r),
"user_agent": r.UserAgent(),
"revoked": false,
"expires_at": time.Now().Add(tokenTTL).UTC().Format(time.RFC3339),
}
return s.pb.Create(ctx, colSessions, payload, nil)
}
// clientIP prefers a forwarding header (in case of a future reverse proxy)
// and otherwise strips the port from the raw remote address.
func clientIP(r *http.Request) string {
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
return strings.TrimSpace(strings.Split(xf, ",")[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// deviceLabelFromUA turns a User-Agent header into a short human label like
// "Chrome on Windows" for the active-sessions list. Best-effort only.
func deviceLabelFromUA(ua string) string {
if ua == "" {
return "Unknown device"
}
var os string
switch {
case strings.Contains(ua, "Android"):
os = "Android"
case strings.Contains(ua, "iPhone"), strings.Contains(ua, "iPad"):
os = "iOS"
case strings.Contains(ua, "Windows"):
os = "Windows"
case strings.Contains(ua, "Mac OS X"), strings.Contains(ua, "Macintosh"):
os = "macOS"
case strings.Contains(ua, "Linux"):
os = "Linux"
}
var app string
switch {
case strings.Contains(ua, "Edg/"):
app = "Edge"
case strings.Contains(ua, "Chrome/"):
app = "Chrome"
case strings.Contains(ua, "Firefox/"):
app = "Firefox"
case strings.Contains(ua, "Safari/") && !strings.Contains(ua, "Chrome"):
app = "Safari"
case strings.Contains(ua, "Dart") || strings.Contains(ua, "okhttp"):
app = "Car Control app"
}
switch {
case app != "" && os != "":
return app + " on " + os
case app != "":
return app
case os != "":
return os
case len(ua) > 60:
return ua[:60]
default:
return ua
}
}
// currentUserID returns the authenticated user's id from the request context.
// Returns "" only if called on an unauthenticated request (withAuth already
// guards every non-public path, so handlers can treat "" as "not authenticated").
func (s *Server) currentUserID(r *http.Request) string {
if claims, ok := r.Context().Value(claimsKey).(*auth.Claims); ok {
return claims.Sub
}
return ""
}
// handleMe returns the authenticated user from the token claims.
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
writeJSON(w, http.StatusOK, userInfo{ID: claims.Sub, Email: claims.Email, Name: claims.Name, Role: claims.Role})
}