Files
pargar/backend/internal/api/auth.go
T
ahp d9a2abb44f feat: release v0.1.3 chat, profiles, and media
Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
2026-10-09 19:05:59 +03:30

344 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
}
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
}
// 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
}
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)})
}