You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
memos/server/auth/authenticator.go

225 lines
7.5 KiB
Go

package auth
import (
"context"
"log/slog"
"strings"
"time"
"github.com/pkg/errors"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/usememos/memos/internal/util"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
// Authenticator provides shared authentication and authorization logic.
// Used by gRPC interceptor, Connect interceptor, and file server to ensure
// consistent authentication behavior across all API endpoints.
//
// Authentication methods:
// - JWT access tokens: Short-lived tokens (15 minutes) for API access
// - Personal Access Tokens (PAT): Long-lived tokens for programmatic access
//
// This struct is safe for concurrent use.
type Authenticator struct {
store *store.Store
secret string
}
// NewAuthenticator creates a new Authenticator instance.
func NewAuthenticator(store *store.Store, secret string) *Authenticator {
return &Authenticator{
store: store,
secret: secret,
}
}
// AuthenticateByAccessTokenV2 validates a short-lived access token.
// Returns claims without database query (stateless validation).
func (a *Authenticator) AuthenticateByAccessTokenV2(accessToken string) (*UserClaims, error) {
claims, err := ParseAccessTokenV2(accessToken, []byte(a.secret))
if err != nil {
return nil, errors.Wrap(err, "invalid access token")
}
userID, err := util.ConvertStringToInt32(claims.Subject)
if err != nil {
return nil, errors.Wrap(err, "invalid user ID in token")
}
return &UserClaims{
UserID: userID,
Username: claims.Username,
Role: claims.Role,
Status: claims.Status,
}, nil
}
// AuthenticateByRefreshToken validates a refresh token against the database.
func (a *Authenticator) AuthenticateByRefreshToken(ctx context.Context, refreshToken string) (*store.User, string, error) {
claims, err := ParseRefreshToken(refreshToken, []byte(a.secret))
if err != nil {
return nil, "", errors.Wrap(err, "invalid refresh token")
}
userID, err := util.ConvertStringToInt32(claims.Subject)
if err != nil {
return nil, "", errors.Wrap(err, "invalid user ID in token")
}
// Check token exists in database (revocation check)
token, err := a.store.GetUserRefreshTokenByID(ctx, userID, claims.TokenID)
if err != nil {
return nil, "", errors.Wrap(err, "failed to get refresh token")
}
if token == nil {
return nil, "", errors.New("refresh token revoked")
}
// Check token not expired
if token.ExpiresAt != nil && token.ExpiresAt.AsTime().Before(time.Now()) {
return nil, "", errors.New("refresh token expired")
}
// Get user
user, err := a.store.GetUser(ctx, &store.FindUser{ID: &userID})
if err != nil {
return nil, "", errors.Wrap(err, "failed to get user")
}
if user == nil {
return nil, "", errors.New("user not found")
}
if user.RowStatus == store.Archived {
return nil, "", errors.New("user is archived")
}
return user, claims.TokenID, nil
}
// AuthenticateByPAT validates a Personal Access Token.
func (a *Authenticator) AuthenticateByPAT(ctx context.Context, token string) (*store.User, *storepb.PersonalAccessTokensUserSetting_PersonalAccessToken, error) {
if !strings.HasPrefix(token, PersonalAccessTokenPrefix) {
return nil, nil, errors.New("invalid PAT format")
}
tokenHash := HashPersonalAccessToken(token)
result, err := a.store.GetUserByPATHash(ctx, tokenHash)
if err != nil {
return nil, nil, errors.Wrap(err, "invalid PAT")
}
// Check expiry
if result.PAT.ExpiresAt != nil && result.PAT.ExpiresAt.AsTime().Before(time.Now()) {
return nil, nil, errors.New("PAT expired")
}
// Check user status
if result.User.RowStatus == store.Archived {
return nil, nil, errors.New("user is archived")
}
return result.User, result.PAT, nil
}
// AuthResult contains the result of an authentication attempt.
type AuthResult struct {
User *store.User // Set for PAT authentication
Claims *UserClaims // Set for Access Token V2 (stateless)
AccessToken string // Non-empty if authenticated via JWT
}
// bearerAuth is the outcome of successfully validating a Bearer token: the resolved
// user plus the credential-specific detail needed to build an AuthResult.
type bearerAuth struct {
user *store.User
claims *UserClaims // set for an Access Token V2
pat *storepb.PersonalAccessTokensUserSetting_PersonalAccessToken // set for a Personal Access Token
}
// resolveBearer validates a Bearer token — an Access Token V2 or a Personal Access
// Token — into an active user. It returns:
// - (nil, nil) when the token is absent, malformed, expired, unknown, or resolves
// to a missing/archived user (callers may then fall back to other credentials);
// - (nil, err) on an unexpected store error;
// - (result, nil) on success.
//
// It performs no side effects; callers decide whether to record PAT usage.
func (a *Authenticator) resolveBearer(ctx context.Context, token string) (*bearerAuth, error) {
if token == "" {
return nil, nil
}
if !strings.HasPrefix(token, PersonalAccessTokenPrefix) {
// Access Token V2 (stateless). An invalid token yields no identity and no
// error, so the caller can fall back to other credentials.
claims, err := a.AuthenticateByAccessTokenV2(token)
if err == nil && claims != nil {
user, err := a.store.GetUser(ctx, &store.FindUser{ID: &claims.UserID})
if err != nil {
return nil, err
}
if user != nil && user.RowStatus != store.Archived {
return &bearerAuth{user: user, claims: claims}, nil
}
}
return nil, nil
}
// Personal Access Token.
if user, pat, err := a.AuthenticateByPAT(ctx, token); err == nil && user != nil {
return &bearerAuth{user: user, pat: pat}, nil
}
return nil, nil
}
// recordPATUsage updates a personal access token's last-used timestamp
// asynchronously; failures are logged and never affect the request.
func (a *Authenticator) recordPATUsage(userID int32, tokenID string) {
go func() {
if err := a.store.UpdatePATLastUsed(context.Background(), userID, tokenID, timestamppb.Now()); err != nil {
slog.Warn("failed to update PAT last used time", "error", err, "userID", userID)
}
}()
}
// AuthenticateToUser resolves the current request to a *store.User, checking the
// Authorization header first (access token or PAT), then falling back to the
// refresh token cookie. Returns (nil, nil) when no valid credentials are present.
func (a *Authenticator) AuthenticateToUser(ctx context.Context, authHeader, cookieHeader string) (*store.User, error) {
bearer, err := a.resolveBearer(ctx, ExtractBearerToken(authHeader))
if err != nil {
return nil, err
}
if bearer != nil {
return bearer.user, nil
}
// Fallback: refresh token cookie.
if cookieHeader != "" {
if refreshToken := ExtractRefreshTokenFromCookie(cookieHeader); refreshToken != "" {
user, _, err := a.AuthenticateByRefreshToken(ctx, refreshToken)
return user, err
}
}
return nil, nil
}
// Authenticate resolves a Bearer token (Access Token V2 or PAT) into an AuthResult,
// returning nil when no valid credentials are present. Unlike AuthenticateToUser it
// ignores the refresh cookie, and it records PAT last-used on success.
func (a *Authenticator) Authenticate(ctx context.Context, authHeader string) *AuthResult {
token := ExtractBearerToken(authHeader)
bearer, err := a.resolveBearer(ctx, token)
if err != nil || bearer == nil {
return nil
}
if bearer.pat != nil {
a.recordPATUsage(bearer.user.ID, bearer.pat.TokenId)
return &AuthResult{User: bearer.user, AccessToken: token}
}
return &AuthResult{Claims: bearer.claims, AccessToken: token}
}