memos/server/router/api/v1/acl.go

135 lines
4.8 KiB
Go

package v1
// gRPC Authentication Interceptor
//
// This file implements the authentication interceptor for gRPC requests.
// It extracts credentials from gRPC metadata and delegates to the shared Authenticator.
//
// Authentication flow:
// 1. Extract session cookie or bearer token from metadata
// 2. Validate credentials using Authenticator
// 3. Check authorization (admin-only methods)
// 4. Set user context and proceed with request
//
// For public methods (defined in acl_config.go), authentication is skipped.
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"github.com/usememos/memos/server/auth"
"github.com/usememos/memos/store"
)
// GRPCAuthInterceptor is the authentication interceptor for gRPC server.
// It validates incoming requests and sets user context for authenticated requests.
type GRPCAuthInterceptor struct {
authenticator *auth.Authenticator
}
// NewGRPCAuthInterceptor creates a new gRPC authentication interceptor.
func NewGRPCAuthInterceptor(store *store.Store, secret string) *GRPCAuthInterceptor {
return &GRPCAuthInterceptor{
authenticator: auth.NewAuthenticator(store, secret),
}
}
// AuthenticationInterceptor is the unary interceptor for gRPC API.
//
// Authentication strategy (in priority order):
// 1. Session Cookie: "user_session" cookie with format "{userID}-{sessionID}"
// 2. Bearer Token: "Authorization: Bearer {jwt_token}" header
// 3. Public Methods: Allow without auth if method is in public allowlist
// 4. Reject: Return Unauthenticated error
//
// On successful authentication, context values are set:
// - auth.UserIDContextKey: The authenticated user's ID
// - auth.SessionIDContextKey: Session ID (cookie auth only)
// - auth.AccessTokenContextKey: JWT token (bearer auth only).
func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, request any, serverInfo *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
// If metadata is missing, only allow public methods
if IsPublicMethod(serverInfo.FullMethod) {
return handler(ctx, request)
}
return nil, status.Errorf(codes.Unauthenticated, "failed to parse metadata from incoming context")
}
// Try session cookie authentication
if sessionCookie := extractSessionCookieFromMetadata(md); sessionCookie != "" {
user, err := in.authenticator.AuthenticateBySession(ctx, sessionCookie)
if err == nil && user != nil {
_, sessionID, err := auth.ParseSessionCookieValue(sessionCookie)
if err != nil {
// This should not happen since AuthenticateBySession already validated the cookie
// but handle it gracefully anyway
sessionID = ""
}
ctx, err = in.authenticator.AuthorizeAndSetContext(ctx, serverInfo.FullMethod, user, sessionID, "", IsAdminOnlyMethod)
if err != nil {
return nil, toGRPCError(err, codes.PermissionDenied)
}
return handler(ctx, request)
}
}
// Try bearer token authentication
if token := extractBearerTokenFromMetadata(md); token != "" {
user, err := in.authenticator.AuthenticateByJWT(ctx, token)
if err == nil && user != nil {
ctx, err = in.authenticator.AuthorizeAndSetContext(ctx, serverInfo.FullMethod, user, "", token, IsAdminOnlyMethod)
if err != nil {
return nil, toGRPCError(err, codes.PermissionDenied)
}
return handler(ctx, request)
}
}
// Allow public methods without authentication
if IsPublicMethod(serverInfo.FullMethod) {
return handler(ctx, request)
}
return nil, status.Errorf(codes.Unauthenticated, "authentication required")
}
// toGRPCError converts an error to a gRPC status error with the given code.
// If the error is already a gRPC status error, it is returned as-is.
func toGRPCError(err error, code codes.Code) error {
if err == nil {
return nil
}
if _, ok := status.FromError(err); ok {
return err
}
return status.Errorf(code, "%v", err)
}
// extractSessionCookieFromMetadata extracts the session cookie value from gRPC metadata.
// Checks both "grpcgateway-cookie" (from gRPC-Gateway) and "cookie" (native gRPC).
// Returns empty string if no session cookie is found.
func extractSessionCookieFromMetadata(md metadata.MD) string {
// gRPC-Gateway puts cookies in "grpcgateway-cookie", native gRPC uses "cookie"
for _, cookieHeader := range append(md.Get("grpcgateway-cookie"), md.Get("cookie")...) {
if cookie := auth.ExtractSessionCookieFromHeader(cookieHeader); cookie != "" {
return cookie
}
}
return ""
}
// extractBearerTokenFromMetadata extracts JWT token from Authorization header in gRPC metadata.
// Returns empty string if no valid bearer token is found.
func extractBearerTokenFromMetadata(md metadata.MD) string {
authHeaders := md.Get("Authorization")
if len(authHeaders) == 0 {
return ""
}
return auth.ExtractBearerToken(authHeaders[0])
}