Files
pangolin/server/internal/auth/handler.go
T
wangjia 2f298f0a0a
ci-pangolin / Lint — shellcheck (push) Successful in 8s
ci-pangolin / OpenAPI Sync Check (push) Successful in 18s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 6s
ci-pangolin / Flutter — analyze + test (push) Successful in 24s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 5s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 4s
ci-pangolin / Go — build + test (push) Successful in 11s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m13s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 14s
feat(devices): P2 sessions 表 + 在线/最后登录/客户端版本
migration 000016(mysql+sqlite,含 down):新增 sessions 表(绑 device+refresh JTI)
+ devices 加 client_version/totp_trusted_until。devices 唯一键改 + platform CHECK
加 linux(需 SQLite 表重建)拆出后续迁移,降风险。

后端:新 internal/sessions Store(Create/Rotate/Revoke/RevokeByDevice/
LastLoginByDevice);TokenManager 外露 refresh JTI(IssueWithJTI/RefreshWithJTI/
ParseRefreshJTI);auth.Service 注入 SessionStore——登录建会话、刷新轮换、登出吊销;
DeviceRegistrar 返回 deviceID;ReportUsage 心跳 touch devices.last_seen(在线判定);
devices.ListDevices 经 LastLoginSource 注入返回 online(last_seen<3min)/client_version/
last_login;RegisterIfAbsent 存 client_version。
客户端:Device model 加 online/clientVersion/lastLogin(fromJson 自动解析)。
测试:sessions store 3 例 + ListDevices 在线/最后登录 + device model 2 例 +
migration v16;全量 go test/flutter test 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 00:50:24 +08:00

229 lines
6.6 KiB
Go

package auth
import (
"encoding/json"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/wangjia/pangolin/server/internal/apierr"
)
// Handler adapts the auth Service to HTTP. Route shapes follow the OpenAPI
// contract (POST /v1/auth/{code,register,login,refresh}).
type Handler struct {
svc *Service
}
// NewHandler builds a Handler.
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
// RegisterRoutes mounts the four public auth endpoints onto r. The caller is
// expected to mount this group WITHOUT the bearer-auth middleware.
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/auth/code", h.SendCode)
r.Post("/auth/register", h.Register)
r.Post("/auth/login", h.Login)
r.Post("/auth/refresh", h.Refresh)
r.Post("/auth/logout", h.Logout)
}
// Logout handles POST /v1/auth/logout. The refresh token to revoke is taken from
// the X-Refresh-Token header (the access token in Authorization is not enough —
// revocation keys on the refresh JTI). Always 204; the client clears its session
// regardless of the server-side outcome.
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
h.svc.Logout(r.Context(), r.Header.Get("X-Refresh-Token"))
w.WriteHeader(http.StatusNoContent)
}
// ---- request/response bodies (mirror openapi.yaml) ----
type sendCodeRequest struct {
Email string `json:"email"`
}
// deviceBody is the optional device identity sent on login/register so the
// control plane can register the device (devices table) and bind a session.
type deviceBody struct {
ID string `json:"id"`
Name string `json:"name"`
Platform string `json:"platform"`
ClientVersion string `json:"client_version"`
}
func (d deviceBody) toMeta() DeviceMeta {
return DeviceMeta{DeviceID: d.ID, Name: d.Name, Platform: d.Platform, ClientVersion: d.ClientVersion}
}
type registerRequest struct {
Email string `json:"email"`
Code string `json:"code"`
Password string `json:"password"`
Device deviceBody `json:"device"`
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Device deviceBody `json:"device"`
}
type refreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
type tokenPairResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
// SendCode handles POST /v1/auth/code.
func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
var req sendCodeRequest
if !decodeJSON(w, r, &req) {
return
}
retryAfter, apiErr := h.svc.SendCode(r.Context(), req.Email, clientIP(r))
if apiErr != nil {
writeAPIErr(w, apiErr, retryAfter)
return
}
w.WriteHeader(http.StatusNoContent)
}
// Register handles POST /v1/auth/register.
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if !decodeJSON(w, r, &req) {
return
}
pair, apiErr := h.svc.Register(r.Context(), req.Email, req.Code, req.Password, clientIP(r), req.Device.toMeta())
if apiErr != nil {
writeAPIErr(w, apiErr, 0)
return
}
writeTokenPair(w, pair)
}
// Login handles POST /v1/auth/login.
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if !decodeJSON(w, r, &req) {
return
}
out, retryAfter, apiErr := h.svc.Login(r.Context(), req.Email, req.Password, clientIP(r), req.Device.toMeta())
if apiErr != nil {
writeAPIErr(w, apiErr, retryAfter)
return
}
// TOTP-enabled accounts get a pending token instead of tokens; the web
// user-center then completes login at /auth/login/totp. The app (no TOTP)
// always receives the flat token pair below.
if out.PendingToken != "" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"totp_required": true,
"pending_token": out.PendingToken,
})
return
}
writeTokenPair(w, out.Tokens)
}
// Refresh handles POST /v1/auth/refresh.
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
var req refreshRequest
if !decodeJSON(w, r, &req) {
return
}
pair, apiErr := h.svc.Refresh(r.Context(), req.RefreshToken)
if apiErr != nil {
writeAPIErr(w, apiErr, 0)
return
}
writeTokenPair(w, pair)
}
// ---- helpers ----
// decodeJSON decodes the request body, writing a 400 on malformed input.
// Returns false if the caller should stop (error already written).
func decodeJSON(w http.ResponseWriter, r *http.Request, dst interface{}) bool {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16))
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
writeAPIErr(w, ErrInvalidRequest, 0)
return false
}
return true
}
// writeTokenPair writes a 200 TokenPair body.
func writeTokenPair(w http.ResponseWriter, pair *TokenPair) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(tokenPairResponse{
AccessToken: pair.AccessToken,
RefreshToken: pair.RefreshToken,
ExpiresIn: pair.ExpiresIn,
})
}
// writeAPIErr maps an apierr.Error to the right HTTP status, attaching a
// Retry-After header when provided.
func writeAPIErr(w http.ResponseWriter, e *apierr.Error, retryAfter time.Duration) {
status := statusFor(e)
if retryAfter > 0 {
secs := int(retryAfter.Seconds())
if secs < 1 {
secs = 1
}
w.Header().Set("Retry-After", strconv.Itoa(secs))
}
apierr.WriteJSON(w, status, e)
}
// statusFor maps auth error codes to HTTP status codes.
func statusFor(e *apierr.Error) int {
switch e.Code {
case ErrInvalidRequest.Code, ErrEmailDisposable.Code, ErrCodeInvalid.Code:
return http.StatusBadRequest
case ErrEmailExists.Code:
return http.StatusConflict
case ErrRateLimited.Code, ErrAccountLocked.Code:
return http.StatusTooManyRequests
case ErrInvalidCredentials.Code, ErrInvalidToken.Code, ErrUnauthorized.Code:
return http.StatusUnauthorized
case ErrAccountBanned.Code:
return http.StatusForbidden
default:
return http.StatusInternalServerError
}
}
// clientIP extracts the best-effort client IP, honouring X-Forwarded-For and
// X-Real-IP set by the edge proxy. Used only as a rate-limit key — never logged.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// First entry is the original client.
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}