Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
344 lines
11 KiB
Go
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)})
|
|
}
|