Ship the product pass on the v0.1.3 schema with additive migrations, a replica migration lock, and bot accounts kept out of people lists.
357 lines
11 KiB
Go
357 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"pargar/backend/internal/config"
|
|
"pargar/backend/internal/models"
|
|
"pargar/backend/internal/observability"
|
|
"pargar/backend/internal/store"
|
|
)
|
|
|
|
type AuthService struct {
|
|
cfg *config.Config
|
|
store *store.Store
|
|
}
|
|
|
|
type Claims struct {
|
|
UserID int64 `json:"uid"`
|
|
Role string `json:"role"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
func NewAuthService(cfg *config.Config, st *store.Store) *AuthService {
|
|
return &AuthService{cfg: cfg, store: st}
|
|
}
|
|
|
|
func (a *AuthService) SignAccess(user *models.User) (string, error) {
|
|
claims := Claims{
|
|
UserID: user.ID,
|
|
Role: user.Role.String(),
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: strconv.FormatInt(user.ID, 10),
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(a.cfg.AccessTokenTTL)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
},
|
|
}
|
|
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(a.cfg.JWTSecret))
|
|
}
|
|
|
|
func (a *AuthService) SignRefresh() (string, string, error) {
|
|
raw := make([]byte, 48)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return "", "", err
|
|
}
|
|
token := hex.EncodeToString(raw)
|
|
hash := sha256.Sum256([]byte(token))
|
|
return token, hex.EncodeToString(hash[:]), nil
|
|
}
|
|
|
|
func (a *AuthService) refreshTTL(rememberMe bool) time.Duration {
|
|
if rememberMe {
|
|
return a.cfg.RefreshTokenTTL
|
|
}
|
|
return a.cfg.RefreshTokenTTLSession
|
|
}
|
|
|
|
func (a *AuthService) IssueForUser(ctx context.Context, user *models.User) (access, refresh string, err error) {
|
|
return a.IssueForUserTTL(ctx, user, a.cfg.RefreshTokenTTL)
|
|
}
|
|
|
|
func (a *AuthService) IssueForUserTTL(ctx context.Context, user *models.User, refreshTTL time.Duration) (access, refresh string, err error) {
|
|
if refreshTTL <= 0 {
|
|
refreshTTL = a.cfg.RefreshTokenTTLSession
|
|
}
|
|
access, err = a.SignAccess(user)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
rt, hash, err := a.SignRefresh()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if err := a.store.CreateRefreshToken(ctx, user.ID, hash, time.Now().Add(refreshTTL)); err != nil {
|
|
return "", "", err
|
|
}
|
|
return access, rt, nil
|
|
}
|
|
|
|
// Authenticate resolves a user from the Authorization header.
|
|
func (s *Server) Authenticate(r *http.Request) (*models.User, error) {
|
|
tokenStr := accessTokenFromRequest(r)
|
|
if tokenStr == "" {
|
|
return nil, errors.New("توکن ورود ارسال نشده است")
|
|
}
|
|
user, err := s.auth.parseAndLoad(r.Context(), tokenStr)
|
|
if err != nil {
|
|
return nil, errors.New("توکن نامعتبر یا منقضی شده است")
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (a *AuthService) parseAndLoad(ctx context.Context, tokenStr string) (*models.User, error) {
|
|
claims := &Claims{}
|
|
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
|
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, errors.New("روش امضای توکن نامعتبر است")
|
|
}
|
|
return []byte(a.cfg.JWTSecret), nil
|
|
})
|
|
if err != nil || !token.Valid {
|
|
return nil, errors.New("توکن نامعتبر است")
|
|
}
|
|
user, err := a.store.GetUserByID(ctx, claims.UserID)
|
|
if err != nil {
|
|
return nil, errors.New("توکن نامعتبر یا منقضی شده است")
|
|
}
|
|
if !user.IsActive {
|
|
return nil, errors.New("حساب غیرفعال است")
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (a *AuthService) HashPassword(pw string) (string, error) {
|
|
b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
|
return string(b), err
|
|
}
|
|
|
|
func (a *AuthService) CheckPassword(hash, pw string) bool {
|
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
|
|
}
|
|
|
|
// ---- handlers ----
|
|
|
|
type credsRequest struct {
|
|
Name string `json:"name"`
|
|
Email string `json:"email"` // legacy alias
|
|
Username string `json:"username"` // preferred login identifier
|
|
Password string `json:"password"`
|
|
ChallengeID string `json:"challengeId"`
|
|
CaptchaAnswer string `json:"captchaAnswer"`
|
|
RememberMe bool `json:"rememberMe"`
|
|
}
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
var req credsRequest
|
|
if err := bodyJSON(r, &req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
|
return
|
|
}
|
|
ip := clientAddress(r)
|
|
if err := s.consumeCaptcha(r.Context(), req.ChallengeID, req.CaptchaAnswer); err != nil {
|
|
s.recordAuthFailure(r.Context(), "login", ip)
|
|
observability.Logins.WithLabelValues("captcha_fail").Inc()
|
|
observability.Activity("auth.captcha_failed", "remote", ip)
|
|
code, msg := captchaErrorMessage(err)
|
|
writeErr(w, code, msg)
|
|
return
|
|
}
|
|
login := strings.TrimSpace(req.Username)
|
|
if login == "" {
|
|
login = strings.TrimSpace(req.Email)
|
|
}
|
|
user, err := s.store.GetUserByLogin(r.Context(), login)
|
|
if err != nil || !s.auth.CheckPassword(user.PasswordHash, req.Password) {
|
|
s.recordAuthFailure(r.Context(), "login", ip)
|
|
observability.Logins.WithLabelValues("fail").Inc()
|
|
observability.Activity("auth.login_failed", "login", login, "remote", ip)
|
|
writeErr(w, http.StatusUnauthorized, "شناسه کاربری یا گذرواژه نادرست است")
|
|
return
|
|
}
|
|
if !user.IsActive {
|
|
observability.Logins.WithLabelValues("inactive").Inc()
|
|
writeErr(w, http.StatusForbidden, "حساب غیرفعال است")
|
|
return
|
|
}
|
|
user = s.settleAccount(r.Context(), user)
|
|
if user.IsClosed {
|
|
observability.Logins.WithLabelValues("closed").Inc()
|
|
writeErr(w, http.StatusForbidden, msgAccountClosedLogin)
|
|
return
|
|
}
|
|
refreshTTL := s.auth.refreshTTL(req.RememberMe)
|
|
access, refresh, err := s.auth.IssueForUserTTL(r.Context(), user, refreshTTL)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "صدور نشست ورود ممکن نشد")
|
|
return
|
|
}
|
|
if meta, ok := r.Context().Value(ctxMeta).(*requestMeta); ok && meta != nil {
|
|
meta.UserID = user.ID
|
|
}
|
|
s.clearAuthFailures(r.Context(), "login", ip)
|
|
observability.Logins.WithLabelValues("ok").Inc()
|
|
observability.Activity("auth.login", "user_id", user.ID, "role", user.Role.String())
|
|
s.signUserMedia(user)
|
|
s.setSessionCookies(w, access, refresh, refreshTTL)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"accessToken": access, "refreshToken": refresh, "user": user,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
RefreshToken string `json:"refreshToken"`
|
|
}
|
|
_ = bodyJSON(r, &req)
|
|
refreshTok := refreshTokenFromRequest(r, req.RefreshToken)
|
|
if refreshTok == "" {
|
|
writeErr(w, http.StatusUnauthorized, "توکن نوسازی نامعتبر است")
|
|
return
|
|
}
|
|
hash := sha256.Sum256([]byte(refreshTok))
|
|
userID, lifetime, ok, err := s.store.ConsumeRefreshToken(r.Context(), hex.EncodeToString(hash[:]))
|
|
if err != nil || !ok {
|
|
s.clearSessionCookies(w)
|
|
writeErr(w, http.StatusUnauthorized, "توکن نوسازی نامعتبر است")
|
|
return
|
|
}
|
|
user, err := s.store.GetUserByID(r.Context(), userID)
|
|
if err != nil {
|
|
s.clearSessionCookies(w)
|
|
writeErr(w, http.StatusUnauthorized, "توکن نوسازی نامعتبر است")
|
|
return
|
|
}
|
|
if !user.IsActive {
|
|
s.clearSessionCookies(w)
|
|
writeErr(w, http.StatusUnauthorized, "حساب غیرفعال است")
|
|
return
|
|
}
|
|
user = s.settleAccount(r.Context(), user)
|
|
if user.IsClosed {
|
|
s.clearSessionCookies(w)
|
|
writeErr(w, http.StatusForbidden, msgAccountClosedLogin)
|
|
return
|
|
}
|
|
// Preserve remember-me vs short session when rotating the refresh token.
|
|
ttl := s.cfg.RefreshTokenTTLSession
|
|
if lifetime >= 48*time.Hour {
|
|
ttl = s.cfg.RefreshTokenTTL
|
|
}
|
|
access, refresh, err := s.auth.IssueForUserTTL(r.Context(), user, ttl)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "صدور نشست ورود ممکن نشد")
|
|
return
|
|
}
|
|
s.signUserMedia(user)
|
|
s.setSessionCookies(w, access, refresh, ttl)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"accessToken": access, "refreshToken": refresh, "user": user,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
u := currentUser(r)
|
|
var req struct {
|
|
RefreshToken string `json:"refreshToken"`
|
|
}
|
|
_ = bodyJSON(r, &req)
|
|
refreshTok := refreshTokenFromRequest(r, req.RefreshToken)
|
|
if refreshTok != "" {
|
|
hash := sha256.Sum256([]byte(refreshTok))
|
|
_ = s.store.RevokeRefreshToken(r.Context(), hex.EncodeToString(hash[:]))
|
|
} else if u != nil {
|
|
_ = s.store.RevokeAllUserRefreshTokens(r.Context(), u.ID)
|
|
}
|
|
s.clearSessionCookies(w)
|
|
if u != nil {
|
|
observability.Activity("auth.logout", "user_id", u.ID)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|
u := currentUser(r)
|
|
hearts, _ := s.hearts.RefreshHearts(r.Context(), u)
|
|
user, err := s.store.GetUserByID(r.Context(), hearts.ID)
|
|
if err != nil {
|
|
user = hearts
|
|
}
|
|
user = s.settleAccount(r.Context(), user)
|
|
s.signUserMedia(user)
|
|
writeJSON(w, http.StatusOK, map[string]any{"user": user})
|
|
}
|
|
|
|
var avatarVariants = map[string]bool{
|
|
"marble": true, "beam": true, "pixel": true, "sunset": true, "ring": true, "bauhaus": true,
|
|
}
|
|
|
|
func (s *Server) handlePutAvatar(w http.ResponseWriter, r *http.Request) {
|
|
u := currentUser(r)
|
|
var req struct {
|
|
Variant *string `json:"avatarVariant"`
|
|
Palette *string `json:"avatarPalette"`
|
|
Photo *string `json:"avatarPhoto"`
|
|
}
|
|
if err := bodyJSON(r, &req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
|
return
|
|
}
|
|
if req.Variant != nil && !avatarVariants[*req.Variant] {
|
|
writeErr(w, http.StatusBadRequest, "گونهٔ آواتار نامعتبر است")
|
|
return
|
|
}
|
|
if req.Photo != nil && *req.Photo != "" {
|
|
key := mediaKeyFromURL(*req.Photo)
|
|
if !mediaKeyOwnedByUser(key, u.ID) {
|
|
writeErr(w, http.StatusBadRequest, "نشانی تصویر آواتار نامعتبر است")
|
|
return
|
|
}
|
|
// Persist without signature query.
|
|
if q := strings.Index(*req.Photo, "?"); q >= 0 {
|
|
stripped := (*req.Photo)[:q]
|
|
req.Photo = &stripped
|
|
}
|
|
}
|
|
user, err := s.store.UpdateAvatar(r.Context(), u.ID, req.Variant, req.Palette, req.Photo)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "بهروزرسانی آواتار ممکن نشد")
|
|
return
|
|
}
|
|
s.signUserMedia(user)
|
|
writeJSON(w, http.StatusOK, map[string]any{"user": user})
|
|
}
|
|
|
|
func (s *Server) handleAvatarPhotoUpload(w http.ResponseWriter, r *http.Request) {
|
|
u := currentUser(r)
|
|
const maxAvatarSize = 6 << 20 // 6 MB
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxAvatarSize+1<<20)
|
|
if err := r.ParseMultipartForm(maxAvatarSize + 1<<20); err != nil {
|
|
writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل بیش از حد مجاز است")
|
|
return
|
|
}
|
|
file, h, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "فایل ارسال نشده است")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
if h.Size > maxAvatarSize {
|
|
writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل باید کمتر از ۶ مگابایت باشد")
|
|
return
|
|
}
|
|
accepted, err := acceptAvatarImage(h, file)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
key := fmt.Sprintf("avatars/%d-%d%s", u.ID, time.Now().UnixNano(), accepted.Ext)
|
|
rawURL, err := s.storage.Save(key, file, h.Size)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "ذخیرهٔ فایل ممکن نشد")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"url": s.signExistingMediaURL(rawURL)})
|
|
}
|