273 lines
7.6 KiB
Go
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})
|
|
}
|