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 " 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}) }