ci / api / api (push) Successful in 36s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 48s
ci / ts / ts (packages + web) (push) Canceled after 12s
ci / db / db (push) Successful in 2m43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 59s
- settings get a search position (left, center by default, right); the top bar is three zones and the suggestions open towards the free space - buttons, chips, tabs, menu items and the home screen controls are not text-selectable, so clicks and double clicks no longer highlight labels Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
267 lines
8.1 KiB
Go
267 lines
8.1 KiB
Go
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),
|
||
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),
|
||
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),
|
||
MobilePlayer: (*string)(p.MobilePlayer),
|
||
Ambient: p.Ambient,
|
||
CrossfadeS: p.CrossfadeS,
|
||
Normalize: p.Normalize,
|
||
}
|
||
}
|