From d1cef7a9ab23e8c93f76b2f32661c720c733536d Mon Sep 17 00:00:00 2001 From: boojack Date: Sun, 5 Jul 2026 22:48:00 +0800 Subject: [PATCH] feat(auth): add private instance mode derived from instance_url Run the instance in private mode when instance_url is not configured: the API rejects anonymous requests except the auth-bootstrap set (sign-in, token refresh, instance profile/settings, SSO providers, share-link access) plus first-run user creation, and the web UI redirects anonymous visitors to /auth instead of /explore. Setting instance_url keeps the current public behavior. Access tokens and personal access tokens are never gated. Enforcement lives in a shared Authorizer used by both the Connect interceptor and the gRPC-gateway middleware; the file server applies the same rule to public-memo attachments and avatars. Also merges the duplicated Authenticate/AuthenticateToUser token dispatch behind resolveBearer, dedups the AuthContext unauthenticated state, extracts the redirect decision into a pure shouldGatePrivateInstance helper, and prints the access mode at startup. --- cmd/memos/main.go | 7 + internal/profile/profile.go | 11 ++ internal/profile/profile_test.go | 24 +++ server/auth/authenticator.go | 132 ++++++++------- server/auth/authenticator_test.go | 34 ++++ server/router/api/v1/acl_config.go | 35 ++++ server/router/api/v1/acl_config_test.go | 47 ++++++ server/router/api/v1/authz.go | 90 ++++++++++ server/router/api/v1/authz_test.go | 56 +++++++ server/router/api/v1/connect_interceptors.go | 27 ++- server/router/api/v1/test/authz_test.go | 81 +++++++++ server/router/api/v1/v1.go | 31 ++-- server/router/fileserver/fileserver.go | 18 +- server/router/fileserver/fileserver_test.go | 166 +++++++++++++++++++ web/src/contexts/AuthContext.tsx | 51 ++---- web/src/layouts/RootLayout.tsx | 13 +- web/src/utils/auth-redirect.ts | 1 + web/src/utils/redirect-safety.ts | 16 ++ web/tests/redirect-safety.test.ts | 29 +++- 19 files changed, 737 insertions(+), 132 deletions(-) create mode 100644 internal/profile/profile_test.go create mode 100644 server/auth/authenticator_test.go create mode 100644 server/router/api/v1/authz.go create mode 100644 server/router/api/v1/authz_test.go create mode 100644 server/router/api/v1/test/authz_test.go diff --git a/cmd/memos/main.go b/cmd/memos/main.go index 3daaa619..5bc68849 100644 --- a/cmd/memos/main.go +++ b/cmd/memos/main.go @@ -193,6 +193,13 @@ func printGreetings(profile *profile.Profile) { fmt.Printf("Server running on unix socket: %s\n", profile.UNIXSock) } + // Access mode is derived from instance_url: set = public, unset = private. + accessMode := "private" + if profile.AllowAnonymous() { + accessMode = "public" + } + fmt.Printf("Access mode: %s\n", accessMode) + fmt.Println() fmt.Printf("Documentation: %s\n", "https://usememos.com") fmt.Printf("Source code: %s\n", "https://github.com/usememos/memos") diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 7a78457b..30158436 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -36,6 +36,17 @@ type Profile struct { InstanceURL string } +// AllowAnonymous reports whether unauthenticated visitors may access the instance. +// +// Anonymous access is enabled only when an InstanceURL is configured. An instance +// with no InstanceURL set is treated as private: anonymous callers are limited to +// the auth-bootstrap endpoints (sign-in, share links, etc.) and the web UI redirects +// them to the sign-in page instead of the public Explore view. Authenticated callers +// (session, access token, or personal access token) are never affected. +func (p *Profile) AllowAnonymous() bool { + return strings.TrimSpace(p.InstanceURL) != "" +} + func checkDataDir(dataDir string) (string, error) { // Convert to absolute path if relative path is supplied. if !filepath.IsAbs(dataDir) { diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go new file mode 100644 index 00000000..4f15b15a --- /dev/null +++ b/internal/profile/profile_test.go @@ -0,0 +1,24 @@ +package profile + +import "testing" + +func TestAllowAnonymous(t *testing.T) { + cases := []struct { + name string + url string + want bool + }{ + {"empty is private", "", false}, + {"whitespace only is private", " ", false}, + {"configured url is public", "https://memos.example.com", true}, + {"configured url with padding is public", " https://memos.example.com ", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p := &Profile{InstanceURL: c.url} + if got := p.AllowAnonymous(); got != c.want { + t.Fatalf("AllowAnonymous() with InstanceURL=%q = %v, want %v", c.url, got, c.want) + } + }) + } +} diff --git a/server/auth/authenticator.go b/server/auth/authenticator.go index 16e98611..bf0b3b77 100644 --- a/server/auth/authenticator.go +++ b/server/auth/authenticator.go @@ -130,39 +130,75 @@ type AuthResult struct { AccessToken string // Non-empty if authenticated via JWT } -// 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 credentials are present. -func (a *Authenticator) AuthenticateToUser(ctx context.Context, authHeader, cookieHeader string) (*store.User, error) { - // Try Bearer token first. - if authHeader != "" { - token := ExtractBearerToken(authHeader) - if token != "" { - if !strings.HasPrefix(token, PersonalAccessTokenPrefix) { - 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 nil, nil - } - return user, nil - } - } else { - user, _, err := a.AuthenticateByPAT(ctx, token) - if err == nil { - return user, nil - } +// 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 != "" { - refreshToken := ExtractRefreshTokenFromCookie(cookieHeader) - if refreshToken != "" { + if refreshToken := ExtractRefreshTokenFromCookie(cookieHeader); refreshToken != "" { user, _, err := a.AuthenticateByRefreshToken(ctx, refreshToken) return user, err } @@ -171,40 +207,18 @@ func (a *Authenticator) AuthenticateToUser(ctx context.Context, authHeader, cook return nil, nil } -// Authenticate tries to authenticate using the provided credentials. -// Priority: 1. Access Token V2, 2. PAT -// Returns nil if no valid credentials are provided. +// 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) - - // Try Access Token V2 (stateless) - if token != "" && !strings.HasPrefix(token, PersonalAccessTokenPrefix) { - claims, err := a.AuthenticateByAccessTokenV2(token) - if err == nil && claims != nil { - user, err := a.store.GetUser(ctx, &store.FindUser{ID: &claims.UserID}) - if err != nil || user == nil || user.RowStatus == store.Archived { - return nil - } - return &AuthResult{ - Claims: claims, - AccessToken: token, - } - } + bearer, err := a.resolveBearer(ctx, token) + if err != nil || bearer == nil { + return nil } - - // Try PAT - if token != "" && strings.HasPrefix(token, PersonalAccessTokenPrefix) { - user, pat, err := a.AuthenticateByPAT(ctx, token) - if err == nil && user != nil { - // Update last used (fire-and-forget with logging) - go func() { - if err := a.store.UpdatePATLastUsed(context.Background(), user.ID, pat.TokenId, timestamppb.Now()); err != nil { - slog.Warn("failed to update PAT last used time", "error", err, "userID", user.ID) - } - }() - return &AuthResult{User: user, AccessToken: token} - } + if bearer.pat != nil { + a.recordPATUsage(bearer.user.ID, bearer.pat.TokenId) + return &AuthResult{User: bearer.user, AccessToken: token} } - - return nil + return &AuthResult{Claims: bearer.claims, AccessToken: token} } diff --git a/server/auth/authenticator_test.go b/server/auth/authenticator_test.go new file mode 100644 index 00000000..f6a866a8 --- /dev/null +++ b/server/auth/authenticator_test.go @@ -0,0 +1,34 @@ +package auth + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestAuthenticateNoCredentials covers the store-free paths: absent or malformed +// credentials must resolve to "unauthenticated" without touching the store. +// Token-valid paths are exercised by the API/fileserver integration tests. +func TestAuthenticateNoCredentials(t *testing.T) { + ctx := context.Background() + a := &Authenticator{secret: "test-secret"} // nil store: these paths never reach it. + + t.Run("Authenticate returns nil without an Authorization header", func(t *testing.T) { + assert.Nil(t, a.Authenticate(ctx, "")) + }) + t.Run("Authenticate returns nil for a malformed bearer token", func(t *testing.T) { + assert.Nil(t, a.Authenticate(ctx, "Bearer not-a-valid-jwt")) + }) + + t.Run("AuthenticateToUser returns nil without any credentials", func(t *testing.T) { + user, err := a.AuthenticateToUser(ctx, "", "") + assert.NoError(t, err) + assert.Nil(t, user) + }) + t.Run("AuthenticateToUser returns nil for a malformed bearer and no cookie", func(t *testing.T) { + user, err := a.AuthenticateToUser(ctx, "Bearer not-a-valid-jwt", "") + assert.NoError(t, err) + assert.Nil(t, user) + }) +} diff --git a/server/router/api/v1/acl_config.go b/server/router/api/v1/acl_config.go index d937ef68..172a74c2 100644 --- a/server/router/api/v1/acl_config.go +++ b/server/router/api/v1/acl_config.go @@ -46,3 +46,38 @@ func IsPublicMethod(procedure string) bool { _, ok := PublicMethods[procedure] return ok } + +// AuthBootstrapMethods is the subset of PublicMethods that stays reachable by +// anonymous callers even when the instance is private (no InstanceURL configured). +// +// It is the minimum required to render the sign-in page, authenticate, and follow +// share links. Every entry here MUST also exist in PublicMethods. CreateUser is +// intentionally excluded and handled separately (allowed only during first-run +// setup, while the instance has no users yet). +var AuthBootstrapMethods = map[string]struct{}{ + // Auth Service - sign-in and token refresh. + "/memos.api.v1.AuthService/SignIn": {}, + "/memos.api.v1.AuthService/RefreshToken": {}, + + // Instance Service - needed to render the sign-in page (branding, auth options). + "/memos.api.v1.InstanceService/GetInstanceProfile": {}, + "/memos.api.v1.InstanceService/GetInstanceSetting": {}, + "/memos.api.v1.InstanceService/BatchGetInstanceSettings": {}, + + // Identity Provider Service - SSO buttons on the sign-in page. + "/memos.api.v1.IdentityProviderService/ListIdentityProviders": {}, + + // Memo sharing - share-token access stays public even on a private instance. + "/memos.api.v1.MemoService/GetMemoByShare": {}, +} + +// createUserProcedure is the CreateUser endpoint. On a private instance it is +// served to anonymous callers only while no user exists yet (initial admin setup). +const createUserProcedure = "/memos.api.v1.UserService/CreateUser" + +// IsAuthBootstrapMethod reports whether an anonymous request to procedure is one +// of the fixed endpoints allowed while the instance is private. +func IsAuthBootstrapMethod(procedure string) bool { + _, ok := AuthBootstrapMethods[procedure] + return ok +} diff --git a/server/router/api/v1/acl_config_test.go b/server/router/api/v1/acl_config_test.go index f0a484bc..e4ff9967 100644 --- a/server/router/api/v1/acl_config_test.go +++ b/server/router/api/v1/acl_config_test.go @@ -88,3 +88,50 @@ func TestUnknownMethodsRequireAuth(t *testing.T) { }) } } + +// TestAuthBootstrapMethodsAreSubsetOfPublic verifies every auth-bootstrap method is +// also a public method. A bootstrap method that wasn't public would be rejected before +// the private-instance check runs, breaking sign-in on a private instance. +func TestAuthBootstrapMethodsAreSubsetOfPublic(t *testing.T) { + for method := range AuthBootstrapMethods { + t.Run(method, func(t *testing.T) { + assert.True(t, IsPublicMethod(method), "auth-bootstrap method %s must also be a public method", method) + }) + } +} + +// TestAuthBootstrapClassification verifies which endpoints remain reachable by +// anonymous callers on a private instance (no InstanceURL configured). +func TestAuthBootstrapClassification(t *testing.T) { + // Reachable while private: sign-in flow, instance metadata, SSO, share links. + bootstrap := []string{ + "/memos.api.v1.AuthService/SignIn", + "/memos.api.v1.AuthService/RefreshToken", + "/memos.api.v1.InstanceService/GetInstanceProfile", + "/memos.api.v1.InstanceService/GetInstanceSetting", + "/memos.api.v1.InstanceService/BatchGetInstanceSettings", + "/memos.api.v1.IdentityProviderService/ListIdentityProviders", + "/memos.api.v1.MemoService/GetMemoByShare", + } + for _, method := range bootstrap { + t.Run("bootstrap/"+method, func(t *testing.T) { + assert.True(t, IsAuthBootstrapMethod(method), "expected %s to be reachable on a private instance", method) + }) + } + + // Public on an open instance, but gated on a private one: browsing and profiles. + // CreateUser is gated here too; it is allowed separately only during first-run setup. + gatedWhilePrivate := []string{ + "/memos.api.v1.MemoService/ListMemos", + "/memos.api.v1.MemoService/GetMemo", + "/memos.api.v1.MemoService/ListMemoComments", + "/memos.api.v1.UserService/GetUser", + "/memos.api.v1.UserService/ListAllUserStats", + "/memos.api.v1.UserService/CreateUser", + } + for _, method := range gatedWhilePrivate { + t.Run("gated/"+method, func(t *testing.T) { + assert.False(t, IsAuthBootstrapMethod(method), "expected %s to be gated on a private instance", method) + }) + } +} diff --git a/server/router/api/v1/authz.go b/server/router/api/v1/authz.go new file mode 100644 index 00000000..93cf13bf --- /dev/null +++ b/server/router/api/v1/authz.go @@ -0,0 +1,90 @@ +package v1 + +import ( + "context" + "errors" + + "github.com/usememos/memos/internal/profile" + "github.com/usememos/memos/server/auth" + "github.com/usememos/memos/store" +) + +// ErrUnauthenticated is returned by the Authorizer when a request must be rejected +// for lack of valid credentials. Each transport maps it to its own status code +// (Connect: CodeUnauthenticated, gRPC-Gateway: HTTP 401). +var ErrUnauthenticated = errors.New("authentication required") + +// Authorizer is the single source of truth for method-level access control. +// +// It authenticates a request from its Authorization header and decides whether the +// (possibly anonymous) caller may reach a given RPC procedure. The Connect +// interceptor and the gRPC-Gateway middleware share one Authorizer so both +// transports enforce identical rules. +// +// Role-based authorization (admin checks) stays in the service layer; this type +// governs only authentication and anonymous access. +type Authorizer struct { + authenticator *auth.Authenticator + store *store.Store + profile *profile.Profile +} + +// NewAuthorizer creates an Authorizer backed by the given store, token secret, and +// instance profile. +func NewAuthorizer(store *store.Store, secret string, profile *profile.Profile) *Authorizer { + return &Authorizer{ + authenticator: auth.NewAuthenticator(store, secret), + store: store, + profile: profile, + } +} + +// Authenticate resolves the caller from the Authorization header, returning nil for +// an anonymous request. It never enforces policy — pair it with CheckAccess. +func (a *Authorizer) Authenticate(ctx context.Context, authHeader string) *auth.AuthResult { + return a.authenticator.Authenticate(ctx, authHeader) +} + +// CheckAccess enforces method-level access policy for procedure given the +// authentication result (nil = anonymous). It returns nil when the request is +// permitted and ErrUnauthenticated otherwise. +// +// Policy: +// - Authenticated caller (access token or PAT): always permitted here. +// - Anonymous + protected method: denied. +// - Anonymous + public method, open instance: permitted. +// - Anonymous + public method, private instance (no InstanceURL): permitted only +// for the auth-bootstrap set, plus CreateUser during first-run setup. +func (a *Authorizer) CheckAccess(ctx context.Context, procedure string, result *auth.AuthResult) error { + if result != nil { + return nil + } + if !IsPublicMethod(procedure) { + return ErrUnauthenticated + } + if a.profile.AllowAnonymous() || a.allowedOnPrivateInstance(ctx, procedure) { + return nil + } + return ErrUnauthenticated +} + +// allowedOnPrivateInstance reports whether an anonymous request to a public +// procedure is still permitted while the instance is private. It allows the +// auth-bootstrap set, plus CreateUser while the instance has no users yet +// (first-run admin setup). +func (a *Authorizer) allowedOnPrivateInstance(ctx context.Context, procedure string) bool { + if IsAuthBootstrapMethod(procedure) { + return true + } + if procedure == createUserProcedure { + return a.noUsersExist(ctx) + } + return false +} + +// noUsersExist reports whether the instance has no users yet (fresh install). +func (a *Authorizer) noUsersExist(ctx context.Context) bool { + limitOne := 1 + users, err := a.store.ListUsers(ctx, &store.FindUser{Limit: &limitOne}) + return err == nil && len(users) == 0 +} diff --git a/server/router/api/v1/authz_test.go b/server/router/api/v1/authz_test.go new file mode 100644 index 00000000..3e88bd95 --- /dev/null +++ b/server/router/api/v1/authz_test.go @@ -0,0 +1,56 @@ +package v1 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/usememos/memos/internal/profile" + "github.com/usememos/memos/server/auth" +) + +// TestAuthorizerCheckAccess exercises the method-level access policy matrix. +// +// The store-backed first-run CreateUser branch is covered by integration tests; +// every case here is decided without touching the store, so a nil store is safe. +func TestAuthorizerCheckAccess(t *testing.T) { + ctx := context.Background() + authenticated := &auth.AuthResult{AccessToken: "token"} + + openInstance := &Authorizer{profile: &profile.Profile{InstanceURL: "https://memos.example.com"}} + privateInstance := &Authorizer{profile: &profile.Profile{InstanceURL: ""}} + + const ( + protectedMethod = "/memos.api.v1.MemoService/CreateMemo" + publicMethod = "/memos.api.v1.MemoService/ListMemos" + bootstrapMethod = "/memos.api.v1.AuthService/SignIn" + shareMethod = "/memos.api.v1.MemoService/GetMemoByShare" + ) + + cases := []struct { + name string + az *Authorizer + procedure string + result *auth.AuthResult + wantErr bool + }{ + {"authenticated reaches protected method", privateInstance, protectedMethod, authenticated, false}, + {"authenticated reaches public method on private instance", privateInstance, publicMethod, authenticated, false}, + {"anonymous denied on protected method", openInstance, protectedMethod, nil, true}, + {"anonymous allowed on public method, open instance", openInstance, publicMethod, nil, false}, + {"anonymous denied on public method, private instance", privateInstance, publicMethod, nil, true}, + {"anonymous allowed on bootstrap method, private instance", privateInstance, bootstrapMethod, nil, false}, + {"anonymous allowed on share access, private instance", privateInstance, shareMethod, nil, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := c.az.CheckAccess(ctx, c.procedure, c.result) + if c.wantErr { + assert.ErrorIs(t, err, ErrUnauthenticated) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/server/router/api/v1/connect_interceptors.go b/server/router/api/v1/connect_interceptors.go index 84d736e7..36ba68ec 100644 --- a/server/router/api/v1/connect_interceptors.go +++ b/server/router/api/v1/connect_interceptors.go @@ -2,7 +2,6 @@ package v1 import ( "context" - "errors" "fmt" "log/slog" "reflect" @@ -13,7 +12,6 @@ import ( "google.golang.org/grpc/metadata" "github.com/usememos/memos/server/auth" - "github.com/usememos/memos/store" ) // MetadataInterceptor converts Connect HTTP headers to gRPC metadata. @@ -204,19 +202,16 @@ func (in *RecoveryInterceptor) logPanic(procedure string, panicValue any) { slog.LogAttrs(context.Background(), slog.LevelError, "panic recovered in Connect handler", attrs...) } -// AuthInterceptor handles authentication for Connect handlers. -// -// It enforces authentication for all endpoints except those listed in PublicMethods. -// Role-based authorization (admin checks) remains in the service layer. +// AuthInterceptor enforces authentication and anonymous-access policy for Connect +// handlers by delegating to the shared Authorizer. Role-based authorization +// (admin checks) remains in the service layer. type AuthInterceptor struct { - authenticator *auth.Authenticator + authorizer *Authorizer } -// NewAuthInterceptor creates a new auth interceptor. -func NewAuthInterceptor(store *store.Store, secret string) *AuthInterceptor { - return &AuthInterceptor{ - authenticator: auth.NewAuthenticator(store, secret), - } +// NewAuthInterceptor creates a new auth interceptor backed by the shared Authorizer. +func NewAuthInterceptor(authorizer *Authorizer) *AuthInterceptor { + return &AuthInterceptor{authorizer: authorizer} } func (in *AuthInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { @@ -224,11 +219,9 @@ func (in *AuthInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { header := req.Header() authHeader := header.Get("Authorization") - result := in.authenticator.Authenticate(ctx, authHeader) - - // Enforce authentication for non-public methods - if result == nil && !IsPublicMethod(req.Spec().Procedure) { - return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("authentication required")) + result := in.authorizer.Authenticate(ctx, authHeader) + if err := in.authorizer.CheckAccess(ctx, req.Spec().Procedure, result); err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, err) } ctx = auth.ApplyToContext(ctx, result) diff --git a/server/router/api/v1/test/authz_test.go b/server/router/api/v1/test/authz_test.go new file mode 100644 index 00000000..65a2ff7c --- /dev/null +++ b/server/router/api/v1/test/authz_test.go @@ -0,0 +1,81 @@ +package test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/usememos/memos/internal/profile" + "github.com/usememos/memos/internal/util" + storepb "github.com/usememos/memos/proto/gen/store" + "github.com/usememos/memos/server/auth" + apiv1 "github.com/usememos/memos/server/router/api/v1" +) + +// TestAuthorizerPrivateInstanceFirstRun verifies the private-instance access policy +// against a real store: anonymous CreateUser is permitted only until the first user +// exists, bootstrap methods stay open, and other public methods are gated. +func TestAuthorizerPrivateInstanceFirstRun(t *testing.T) { + ctx := context.Background() + ts := NewTestService(t) + defer ts.Cleanup() + + // InstanceURL empty => private instance. + authorizer := apiv1.NewAuthorizer(ts.Store, ts.Secret, &profile.Profile{InstanceURL: ""}) + + const ( + createUser = "/memos.api.v1.UserService/CreateUser" + signIn = "/memos.api.v1.AuthService/SignIn" + listMemos = "/memos.api.v1.MemoService/ListMemos" + getMemoShare = "/memos.api.v1.MemoService/GetMemoByShare" + ) + + // Anonymous request with no Authorization header resolves to no identity. + require.Nil(t, authorizer.Authenticate(ctx, "")) + + // Fresh instance (no users): first-run CreateUser is allowed, and so are the + // bootstrap methods; browsing is still gated. + require.NoError(t, authorizer.CheckAccess(ctx, createUser, nil), "first-run CreateUser should be allowed") + require.NoError(t, authorizer.CheckAccess(ctx, signIn, nil)) + require.NoError(t, authorizer.CheckAccess(ctx, getMemoShare, nil)) + require.ErrorIs(t, authorizer.CheckAccess(ctx, listMemos, nil), apiv1.ErrUnauthenticated) + + // Once a user exists, anonymous CreateUser is denied while bootstrap stays open. + _, err := ts.CreateHostUser(ctx, "host") + require.NoError(t, err) + require.ErrorIs(t, authorizer.CheckAccess(ctx, createUser, nil), apiv1.ErrUnauthenticated, "CreateUser must be denied once a user exists") + require.NoError(t, authorizer.CheckAccess(ctx, signIn, nil)) +} + +// TestAuthorizerAccessTokenAlwaysWorksOnPrivateInstance verifies that a valid +// Personal Access Token authenticates through the Authorizer even on a private +// instance, and then passes the access check for an otherwise-gated method. +func TestAuthorizerAccessTokenAlwaysWorksOnPrivateInstance(t *testing.T) { + ctx := context.Background() + ts := NewTestService(t) + defer ts.Cleanup() + + user, err := ts.CreateRegularUser(ctx, "pat-user") + require.NoError(t, err) + + token := auth.GeneratePersonalAccessToken() + require.NoError(t, ts.Store.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: util.GenUUID(), + TokenHash: auth.HashPersonalAccessToken(token), + Description: "authz test PAT", + CreatedAt: timestamppb.Now(), + })) + + // InstanceURL empty => private instance; the PAT must still authenticate. + authorizer := apiv1.NewAuthorizer(ts.Store, ts.Secret, &profile.Profile{InstanceURL: ""}) + + result := authorizer.Authenticate(ctx, "Bearer "+token) + require.NotNil(t, result) + require.NotNil(t, result.User) + require.Equal(t, user.ID, result.User.ID) + + // An authenticated caller passes access control even for a gated method. + require.NoError(t, authorizer.CheckAccess(ctx, "/memos.api.v1.MemoService/ListMemos", result)) +} diff --git a/server/router/api/v1/v1.go b/server/router/api/v1/v1.go index 83c26fd2..378b41e8 100644 --- a/server/router/api/v1/v1.go +++ b/server/router/api/v1/v1.go @@ -63,32 +63,29 @@ func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store // RegisterGateway registers the gRPC-Gateway and Connect handlers with the given Echo instance. func (s *APIV1Service) RegisterGateway(ctx context.Context, echoServer *echo.Echo) error { - // Auth middleware for gRPC-Gateway - runs after routing, has access to method name. - // Uses the same PublicMethods config as the Connect AuthInterceptor. - authenticator := auth.NewAuthenticator(s.Store, s.Secret) + // Shared authorizer: one source of truth for authentication and anonymous-access + // policy, used by both the gRPC-Gateway middleware and the Connect interceptor. + authorizer := NewAuthorizer(s.Store, s.Secret, s.Profile) gatewayAuthMiddleware := func(next runtime.HandlerFunc) runtime.HandlerFunc { return func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) { ctx := r.Context() - // Get the RPC method name from context (set by grpc-gateway after routing) + // The RPC method name is set by grpc-gateway after routing. When it can't be + // determined, skip the policy check and let the service layer handle visibility. rpcMethod, ok := runtime.RPCMethod(ctx) - - // Extract credentials from HTTP headers authHeader := r.Header.Get("Authorization") - result := authenticator.Authenticate(ctx, authHeader) - - // Enforce authentication for non-public methods - // If rpcMethod cannot be determined, allow through, service layer will handle visibility checks - if result == nil && ok && !IsPublicMethod(rpcMethod) { - http.Error(w, `{"code": 16, "message": "authentication required"}`, http.StatusUnauthorized) - return + result := authorizer.Authenticate(ctx, authHeader) + if ok { + if err := authorizer.CheckAccess(ctx, rpcMethod, result); err != nil { + http.Error(w, `{"code": 16, "message": "authentication required"}`, http.StatusUnauthorized) + return + } } - // Apply auth result to context (no-op when result is nil for public endpoints) + // Apply the identity to the context (no-op for permitted anonymous requests). if result != nil { - ctx = auth.ApplyToContext(ctx, result) - r = r.WithContext(ctx) + r = r.WithContext(auth.ApplyToContext(ctx, result)) } next(w, r, pathParams) @@ -137,7 +134,7 @@ func (s *APIV1Service) RegisterGateway(ctx context.Context, echoServer *echo.Ech NewMetadataInterceptor(), // Convert HTTP headers to gRPC metadata first NewLoggingInterceptor(logStacktraces), NewRecoveryInterceptor(logStacktraces), - NewAuthInterceptor(s.Store, s.Secret), + NewAuthInterceptor(authorizer), ) connectMux := http.NewServeMux() connectHandler := NewConnectServiceHandler(s) diff --git a/server/router/fileserver/fileserver.go b/server/router/fileserver/fileserver.go index c3cac654..63c4dc1f 100644 --- a/server/router/fileserver/fileserver.go +++ b/server/router/fileserver/fileserver.go @@ -167,6 +167,19 @@ func (s *FileServerService) serveAttachmentFile(c *echo.Context) error { // serveUserAvatar serves user avatar images. func (s *FileServerService) serveUserAvatar(c *echo.Context) error { ctx := c.Request().Context() + + // On a private instance (no InstanceURL), avatars are not exposed to anonymous + // visitors; a valid session, access token, or PAT is required. + if !s.Profile.AllowAnonymous() { + viewer, err := s.getCurrentUser(ctx, c) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to get current user").Wrap(err) + } + if viewer == nil { + return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized access") + } + } + identifier := c.Param("identifier") user, err := s.getUserByUsername(ctx, identifier) @@ -658,7 +671,10 @@ func (s *FileServerService) checkAttachmentPermission(ctx context.Context, c *ec return echo.NewHTTPError(http.StatusNotFound, "memo not found") } - if memo.Visibility == store.Public { + // Public-visibility attachments are served to anonymous visitors only when the + // instance allows anonymous access. On a private instance (no InstanceURL), the + // request must still resolve to an authenticated user or a valid share token below. + if memo.Visibility == store.Public && s.Profile.AllowAnonymous() { return nil } diff --git a/server/router/fileserver/fileserver_test.go b/server/router/fileserver/fileserver_test.go index 3ffe8aac..6826f4d9 100644 --- a/server/router/fileserver/fileserver_test.go +++ b/server/router/fileserver/fileserver_test.go @@ -3,6 +3,7 @@ package fileserver import ( "bytes" "context" + "encoding/base64" "encoding/binary" "fmt" "hash/crc32" @@ -13,14 +14,18 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/labstack/echo/v5" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/usememos/memos/internal/markdown" "github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/testutil" + "github.com/usememos/memos/internal/util" apiv1 "github.com/usememos/memos/proto/gen/api/v1" + storepb "github.com/usememos/memos/proto/gen/store" "github.com/usememos/memos/server/auth" apiv1service "github.com/usememos/memos/server/router/api/v1" "github.com/usememos/memos/store" @@ -433,3 +438,164 @@ func newShareAttachmentTestServices(ctx context.Context, t *testing.T) (*apiv1se testStore.Close() } } + +// makePNGDataURI returns a minimal valid PNG encoded as a data URI, suitable for a +// user avatar. +func makePNGDataURI(t *testing.T) string { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{R: 1, G: 2, B: 3, A: 255}) + var buf bytes.Buffer + require.NoError(t, png.Encode(&buf, img)) + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +// TestServeAttachmentFile_PrivateInstanceDeniesAnonymous verifies that a public +// memo's attachment is served to anonymous visitors on an open instance but denied +// on a private instance (no InstanceURL configured). +func TestServeAttachmentFile_PrivateInstanceDeniesAnonymous(t *testing.T) { + ctx := context.Background() + svc, fs, _, cleanup := newShareAttachmentTestServices(ctx, t) + defer cleanup() + + creator, err := svc.Store.CreateUser(ctx, &store.User{ + Username: "private-attachment-owner", + Role: store.RoleUser, + Email: "private-attachment-owner@example.com", + }) + require.NoError(t, err) + creatorCtx := context.WithValue(ctx, auth.UserIDContextKey, creator.ID) + + attachment, err := svc.CreateAttachment(creatorCtx, &apiv1.CreateAttachmentRequest{ + Attachment: &apiv1.Attachment{ + Filename: "public.txt", + Type: "text/plain", + Content: []byte("public content"), + }, + }) + require.NoError(t, err) + + _, err = svc.CreateMemo(creatorCtx, &apiv1.CreateMemoRequest{ + Memo: &apiv1.Memo{ + Content: "public memo", + Visibility: apiv1.Visibility_PUBLIC, + Attachments: []*apiv1.Attachment{{Name: attachment.Name}}, + }, + }) + require.NoError(t, err) + + e := echo.New() + fs.RegisterRoutes(e) + url := fmt.Sprintf("/file/%s/%s", attachment.Name, attachment.Filename) + + anonymousGet := func() int { + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, url, nil)) + return rec.Code + } + + // Open instance: anonymous access to a public memo's attachment is allowed. + fs.Profile.InstanceURL = "http://localhost:8080" + require.Equal(t, http.StatusOK, anonymousGet()) + + // Private instance: the same anonymous request is denied. + fs.Profile.InstanceURL = "" + require.Equal(t, http.StatusUnauthorized, anonymousGet()) +} + +// TestServeUserAvatar_PrivateInstanceRequiresAuth verifies that avatars are exposed +// to anonymous visitors on an open instance but require authentication on a private +// instance. +func TestServeUserAvatar_PrivateInstanceRequiresAuth(t *testing.T) { + ctx := context.Background() + svc, fs, _, cleanup := newShareAttachmentTestServices(ctx, t) + defer cleanup() + + _, err := svc.Store.CreateUser(ctx, &store.User{ + Username: "avatar-owner", + Role: store.RoleUser, + Email: "avatar-owner@example.com", + AvatarURL: makePNGDataURI(t), + }) + require.NoError(t, err) + + e := echo.New() + fs.RegisterRoutes(e) + + anonymousGet := func() int { + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/file/users/avatar-owner/avatar", nil)) + return rec.Code + } + + // Open instance: anonymous avatar access is allowed. + fs.Profile.InstanceURL = "http://localhost:8080" + require.Equal(t, http.StatusOK, anonymousGet()) + + // Private instance: anonymous avatar access is denied. + fs.Profile.InstanceURL = "" + require.Equal(t, http.StatusUnauthorized, anonymousGet()) +} + +// TestServeAttachmentFile_RefreshCookieAuthenticatesOwner verifies that the file +// server authenticates a request via the refresh-token cookie (the browser +// flow) — the AuthenticateToUser cookie fallback — letting the owner fetch their own +// private memo's attachment without an Authorization header. +func TestServeAttachmentFile_RefreshCookieAuthenticatesOwner(t *testing.T) { + ctx := context.Background() + svc, fs, _, cleanup := newShareAttachmentTestServices(ctx, t) + defer cleanup() + + owner, err := svc.Store.CreateUser(ctx, &store.User{ + Username: "cookie-owner", + Role: store.RoleUser, + Email: "cookie-owner@example.com", + }) + require.NoError(t, err) + ownerCtx := context.WithValue(ctx, auth.UserIDContextKey, owner.ID) + + attachment, err := svc.CreateAttachment(ownerCtx, &apiv1.CreateAttachmentRequest{ + Attachment: &apiv1.Attachment{ + Filename: "secret.txt", + Type: "text/plain", + Content: []byte("secret content"), + }, + }) + require.NoError(t, err) + + _, err = svc.CreateMemo(ownerCtx, &apiv1.CreateMemoRequest{ + Memo: &apiv1.Memo{ + Content: "private memo", + Visibility: apiv1.Visibility_PRIVATE, + Attachments: []*apiv1.Attachment{{Name: attachment.Name}}, + }, + }) + require.NoError(t, err) + + // Mint a valid refresh token for the owner and store its record. + tokenID := util.GenUUID() + require.NoError(t, svc.Store.AddUserRefreshToken(ctx, owner.ID, &storepb.RefreshTokensUserSetting_RefreshToken{ + TokenId: tokenID, + ExpiresAt: timestamppb.New(time.Now().Add(auth.RefreshTokenDuration)), + CreatedAt: timestamppb.Now(), + })) + refreshToken, _, err := auth.GenerateRefreshToken(owner.ID, tokenID, []byte(svc.Secret)) + require.NoError(t, err) + + e := echo.New() + fs.RegisterRoutes(e) + url := fmt.Sprintf("/file/%s/%s", attachment.Name, attachment.Filename) + + // Without credentials, the private attachment is denied. + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, url, nil)) + require.Equal(t, http.StatusUnauthorized, rec.Code) + + // With the refresh-token cookie, the owner is authenticated and served. + req := httptest.NewRequest(http.MethodGet, url, nil) + req.AddCookie(&http.Cookie{Name: auth.RefreshTokenCookieName, Value: refreshToken}) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "secret content", rec.Body.String()) +} diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index f152567f..69ac0f44 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -30,6 +30,17 @@ interface AuthContextValue extends AuthState { const AuthContext = createContext(null); +/** Settled auth state for a request with no valid session (init finished, not loading). */ +const UNAUTHENTICATED_STATE: AuthState = { + currentUser: undefined, + userGeneralSetting: undefined, + userWebhooksSetting: undefined, + userTagsSetting: undefined, + shortcuts: [], + isInitialized: true, + isLoading: false, +}; + export function AuthProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const [state, setState] = useState({ @@ -78,15 +89,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { // If we still don't have a token after refresh attempt, skip getCurrentUser call // to avoid unnecessary network request for unauthenticated users. if (!getAccessToken()) { - setState({ - currentUser: undefined, - userGeneralSetting: undefined, - userWebhooksSetting: undefined, - userTagsSetting: undefined, - shortcuts: [], - isInitialized: true, - isLoading: false, - }); + setState(UNAUTHENTICATED_STATE); return; } @@ -95,15 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (!currentUser) { clearAccessToken(); - setState({ - currentUser: undefined, - userGeneralSetting: undefined, - userWebhooksSetting: undefined, - userTagsSetting: undefined, - shortcuts: [], - isInitialized: true, - isLoading: false, - }); + setState(UNAUTHENTICATED_STATE); return; } @@ -122,15 +117,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } catch (error) { console.error("Failed to initialize auth:", error); clearAccessToken(); - setState({ - currentUser: undefined, - userGeneralSetting: undefined, - userWebhooksSetting: undefined, - userTagsSetting: undefined, - shortcuts: [], - isInitialized: true, - isLoading: false, - }); + setState(UNAUTHENTICATED_STATE); } }, [fetchUserSettings, queryClient]); @@ -141,15 +128,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { console.error("[AuthContext] Failed to sign out:", error); } finally { clearAccessToken(); - setState({ - currentUser: undefined, - userGeneralSetting: undefined, - userWebhooksSetting: undefined, - userTagsSetting: undefined, - shortcuts: [], - isInitialized: true, - isLoading: false, - }); + setState(UNAUTHENTICATED_STATE); queryClient.clear(); } }, [queryClient]); diff --git a/web/src/layouts/RootLayout.tsx b/web/src/layouts/RootLayout.tsx index 02483b0e..25e52ef8 100644 --- a/web/src/layouts/RootLayout.tsx +++ b/web/src/layouts/RootLayout.tsx @@ -1,10 +1,12 @@ import { useEffect, useRef } from "react"; -import { Outlet, useLocation, useSearchParams } from "react-router-dom"; +import { Navigate, Outlet, useLocation, useSearchParams } from "react-router-dom"; import Navigation from "@/components/Navigation"; import { useInstance } from "@/contexts/InstanceContext"; import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; +import useCurrentUser from "@/hooks/useCurrentUser"; import useMediaQuery from "@/hooks/useMediaQuery"; import { cn } from "@/lib/utils"; +import { buildAuthRoute, shouldGatePrivateInstance } from "@/utils/auth-redirect"; import { useTranslate } from "@/utils/i18n"; const MEMOS_DEPLOY_URL = "https://usememos.com/docs/deploy"; @@ -29,6 +31,7 @@ const RootLayout = () => { const location = useLocation(); const [searchParams] = useSearchParams(); const sm = useMediaQuery("sm"); + const currentUser = useCurrentUser(); const { profile } = useInstance(); const { removeFilter } = useMemoFilterContext(); const { pathname } = location; @@ -45,6 +48,14 @@ const RootLayout = () => { prevPathnameRef.current = pathname; }, [pathname, searchParams, removeFilter]); + // Private instance (no InstanceURL configured): anonymous visitors may only reach + // share links; everything else redirects to the sign-in page, preserving the intended + // destination. Public instances keep the open Explore behavior for logged-out users. + if (shouldGatePrivateInstance({ isPrivateInstance: !profile.instanceUrl, isAuthenticated: !!currentUser, pathname })) { + const redirect = `${pathname}${location.search}${location.hash}`; + return ; + } + return (
{sm && ( diff --git a/web/src/utils/auth-redirect.ts b/web/src/utils/auth-redirect.ts index c7f63a0f..9d195342 100644 --- a/web/src/utils/auth-redirect.ts +++ b/web/src/utils/auth-redirect.ts @@ -12,6 +12,7 @@ export { buildAuthRoute, getSafeRedirectPath, isPublicRoute, + shouldGatePrivateInstance, } from "./redirect-safety"; /** diff --git a/web/src/utils/redirect-safety.ts b/web/src/utils/redirect-safety.ts index 48fb134f..3a88c417 100644 --- a/web/src/utils/redirect-safety.ts +++ b/web/src/utils/redirect-safety.ts @@ -71,3 +71,19 @@ const PUBLIC_ROUTE_PREFIXES = [ export function isPublicRoute(path: string): boolean { return PUBLIC_ROUTE_PREFIXES.some((route) => path.startsWith(route)); } + +/** + * Reports whether an anonymous visitor to a private instance should be redirected + * to the sign-in page for the given path. + * + * A private instance (no configured instance URL) hides everything from anonymous + * visitors except share-link pages, which stay accessible so public shares keep + * working. Authenticated visitors and open instances are never gated. + */ +export function shouldGatePrivateInstance(params: { isPrivateInstance: boolean; isAuthenticated: boolean; pathname: string }): boolean { + const { isPrivateInstance, isAuthenticated, pathname } = params; + if (!isPrivateInstance || isAuthenticated) { + return false; + } + return !pathname.startsWith(`${ROUTES.SHARED_MEMO}/`); +} diff --git a/web/tests/redirect-safety.test.ts b/web/tests/redirect-safety.test.ts index fc1e0a57..a6c803dd 100644 --- a/web/tests/redirect-safety.test.ts +++ b/web/tests/redirect-safety.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { AUTH_REDIRECT_PARAM, buildAuthRoute, getSafeRedirectPath, isPublicRoute } from "@/utils/redirect-safety"; +import { + AUTH_REDIRECT_PARAM, + buildAuthRoute, + getSafeRedirectPath, + isPublicRoute, + shouldGatePrivateInstance, +} from "@/utils/redirect-safety"; describe("getSafeRedirectPath", () => { it("accepts safe same-origin internal paths", () => { @@ -75,3 +81,24 @@ describe("isPublicRoute", () => { expect(isPublicRoute("/archived")).toBe(false); }); }); + +describe("shouldGatePrivateInstance", () => { + it("never gates on an open (public) instance", () => { + expect(shouldGatePrivateInstance({ isPrivateInstance: false, isAuthenticated: false, pathname: "/" })).toBe(false); + expect(shouldGatePrivateInstance({ isPrivateInstance: false, isAuthenticated: false, pathname: "/explore" })).toBe(false); + }); + + it("never gates an authenticated visitor", () => { + expect(shouldGatePrivateInstance({ isPrivateInstance: true, isAuthenticated: true, pathname: "/explore" })).toBe(false); + }); + + it("gates anonymous visitors to non-share pages on a private instance", () => { + for (const pathname of ["/", "/explore", "/about", "/memos/abc", "/u/steven", "/setting"]) { + expect(shouldGatePrivateInstance({ isPrivateInstance: true, isAuthenticated: false, pathname })).toBe(true); + } + }); + + it("keeps share links reachable for anonymous visitors on a private instance", () => { + expect(shouldGatePrivateInstance({ isPrivateInstance: true, isAuthenticated: false, pathname: "/memos/shares/token123" })).toBe(false); + }); +});