Files
pipu-music/services/api/internal/auth/handler.go
T
mrleo1nid a29a8b18ad
ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
feat(admin): manage server updates and local configuration
2026-10-22 23:01:36 +03:00

277 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
import (
"context"
"errors"
"net/http"
"time"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
)
// RefreshCookie — имя cookie с refresh-токеном веб-клиента.
const RefreshCookie = "pipu_rt"
const refreshCookiePath = "/api/v1/auth"
// Handler реализует auth- и me-части gen.StrictServerInterface.
type Handler struct {
svc *Service
cookieSecure bool
}
// NewHandler создаёт обработчик. cookieSecure = false допустим только для разработки по http.
func NewHandler(svc *Service, cookieSecure bool) *Handler {
return &Handler{svc: svc, cookieSecure: cookieSecure}
}
func principal(ctx context.Context) (Principal, error) {
p, ok := PrincipalFrom(ctx)
if !ok {
return Principal{}, apperr.Unauthorized(CodeUnauthorized, "not authenticated")
}
return p, nil
}
// Login реализует gen.StrictServerInterface.
func (h *Handler) Login(ctx context.Context, req gen.LoginRequestObject) (gen.LoginResponseObject, error) {
in := LoginInput{
Username: req.Body.Username,
Password: req.Body.Password,
ClientType: ClientType(req.Body.ClientType),
IP: clientIP(ctx),
}
if req.Body.DeviceName != nil {
in.DeviceName = *req.Body.DeviceName
}
t, err := h.svc.Login(ctx, in)
if err != nil {
return nil, err
}
return gen.Login200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// RefreshTokens реализует gen.StrictServerInterface.
func (h *Handler) RefreshTokens(ctx context.Context, req gen.RefreshTokensRequestObject) (gen.RefreshTokensResponseObject, error) {
var token string
if req.Body != nil && req.Body.RefreshToken != nil {
token = *req.Body.RefreshToken
} else if req.Params.PipuRt != nil {
token = *req.Params.PipuRt
}
t, err := h.svc.Refresh(ctx, token)
if err != nil {
var ae *apperr.Error
// Токен больше не годится — убираем cookie, чтобы браузер не присылал его снова.
// При гонке двух вкладок cookie уже новая, её не трогаем.
if req.Params.PipuRt != nil && errors.As(err, &ae) && ae.Code != CodeRefreshRace {
h.clearCookie(ctx)
}
return nil, err
}
return gen.RefreshTokens200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// Logout реализует gen.StrictServerInterface.
func (h *Handler) Logout(ctx context.Context, _ gen.LogoutRequestObject) (gen.LogoutResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.svc.Logout(ctx, p); err != nil {
return nil, err
}
h.clearCookie(ctx)
return gen.Logout204Response{}, nil
}
// LogoutAll реализует gen.StrictServerInterface.
func (h *Handler) LogoutAll(ctx context.Context, _ gen.LogoutAllRequestObject) (gen.LogoutAllResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.svc.LogoutAll(ctx, p); err != nil {
return nil, err
}
h.clearCookie(ctx)
return gen.LogoutAll204Response{}, nil
}
// GetMe реализует gen.StrictServerInterface.
func (h *Handler) GetMe(ctx context.Context, _ gen.GetMeRequestObject) (gen.GetMeResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
u, err := h.svc.Me(ctx, p)
if err != nil {
return nil, err
}
return gen.GetMe200JSONResponse(MeDTO(u)), nil
}
// UpdateMe реализует gen.StrictServerInterface.
func (h *Handler) UpdateMe(ctx context.Context, req gen.UpdateMeRequestObject) (gen.UpdateMeResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
patch := MePatch{DisplayName: req.Body.DisplayName, Locale: req.Body.Locale}
if req.Body.UiPrefs != nil {
prefs := uiPrefsFromDTO(*req.Body.UiPrefs)
patch.UIPrefs = &prefs
}
u, err := h.svc.UpdateMe(ctx, p, patch)
if err != nil {
return nil, err
}
return gen.UpdateMe200JSONResponse(MeDTO(u)), nil
}
// ChangePassword реализует gen.StrictServerInterface.
func (h *Handler) ChangePassword(ctx context.Context, req gen.ChangePasswordRequestObject) (gen.ChangePasswordResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
t, err := h.svc.ChangePassword(ctx, p, req.Body.CurrentPassword, req.Body.NewPassword)
if err != nil {
return nil, err
}
return gen.ChangePassword200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// SetBootstrapCredentials реализует gen.StrictServerInterface.
func (h *Handler) SetBootstrapCredentials(ctx context.Context, req gen.SetBootstrapCredentialsRequestObject) (gen.SetBootstrapCredentialsResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
t, err := h.svc.SetBootstrapCredentials(ctx, p, req.Body.NewUsername, req.Body.NewPassword)
if err != nil {
return nil, err
}
return gen.SetBootstrapCredentials200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// ListMySessions реализует gen.StrictServerInterface.
func (h *Handler) ListMySessions(ctx context.Context, _ gen.ListMySessionsRequestObject) (gen.ListMySessionsResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
sessions, err := h.svc.ListSessions(ctx, p)
if err != nil {
return nil, err
}
items := make([]gen.Session, 0, len(sessions))
for _, s := range sessions {
items = append(items, gen.Session{
Id: s.ID,
DeviceName: s.DeviceName,
ClientType: gen.ClientType(s.ClientType),
CreatedAt: s.CreatedAt,
LastUsedAt: s.LastUsedAt,
Current: s.ID == p.SessionID,
})
}
return gen.ListMySessions200JSONResponse{Items: items}, nil
}
// RevokeMySession реализует gen.StrictServerInterface.
func (h *Handler) RevokeMySession(ctx context.Context, req gen.RevokeMySessionRequestObject) (gen.RevokeMySessionResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.svc.RevokeSession(ctx, p, req.Id); err != nil {
return nil, err
}
return gen.RevokeMySession204Response{}, nil
}
// tokensDTO собирает ответ с токенами: веб получает refresh в cookie, Android — в теле.
func (h *Handler) tokensDTO(ctx context.Context, t Tokens) gen.AuthTokens {
out := gen.AuthTokens{
AccessToken: t.Access,
TokenType: gen.Bearer,
ExpiresIn: int(t.ExpiresIn / time.Second),
User: MeDTO(t.User),
}
if t.ClientType == ClientWeb {
setCookie(ctx, &http.Cookie{
Name: RefreshCookie,
Value: t.Refresh,
Path: refreshCookiePath,
MaxAge: int(h.svc.cfg.RefreshTTL / time.Second),
HttpOnly: true,
Secure: h.cookieSecure,
SameSite: http.SameSiteStrictMode,
})
} else {
refresh := t.Refresh
out.RefreshToken = &refresh
}
return out
}
func (h *Handler) clearCookie(ctx context.Context) {
setCookie(ctx, &http.Cookie{
Name: RefreshCookie, Path: refreshCookiePath, MaxAge: -1,
HttpOnly: true, Secure: h.cookieSecure, SameSite: http.SameSiteStrictMode,
})
}
// MeDTO переводит пользователя в схему Me.
func MeDTO(u User) gen.Me {
return gen.Me{
Id: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Role: gen.Role(u.Role),
ReviewsUploads: u.ReviewsUploads,
ManagesUpdates: updatePermission(u),
Locale: u.Locale,
UiPrefs: uiPrefsDTO(u.UIPrefs),
MustChangePassword: u.MustChangePassword,
MustChangeCredentials: u.MustChangeCredentials,
CreatedAt: u.CreatedAt,
}
}
func uiPrefsDTO(p UIPrefs) gen.UiPrefs {
return gen.UiPrefs{
Theme: (*gen.UiPrefsTheme)(p.Theme),
WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant),
WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer),
SearchPosition: (*gen.UiPrefsSearchPosition)(p.SearchPos),
SearchCollapse: p.SearchFold,
HomeActivities: p.HomeActs,
HomeMoods: p.HomeMoods,
MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
}
}
func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs {
return UIPrefs{
Theme: (*string)(p.Theme),
WaveVariant: (*string)(p.WaveVariant),
WebPlayer: (*string)(p.WebPlayer),
SearchPos: (*string)(p.SearchPosition),
SearchFold: p.SearchCollapse,
HomeActs: p.HomeActivities,
HomeMoods: p.HomeMoods,
MobilePlayer: (*string)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
}
}
func updatePermission(u User) *bool { v := u.ManagesUpdates || u.Role == RoleAdmin; return &v }