Initial commit
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user