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.
pull/6069/head
boojack 2 days ago
parent 1794d0dc51
commit d1cef7a9ab

@ -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")

@ -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) {

@ -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)
}
})
}
}

@ -130,39 +130,75 @@ type AuthResult struct {
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 credentials are present.
// 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) {
// 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
}
}
}
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}
}

@ -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)
})
}

@ -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
}

@ -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)
})
}
}

@ -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
}

@ -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)
}
})
}
}

@ -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)

@ -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))
}

@ -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)

@ -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
}

@ -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 <img>
// 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())
}

@ -30,6 +30,17 @@ interface AuthContextValue extends AuthState {
const AuthContext = createContext<AuthContextValue | null>(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<AuthState>({
@ -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]);

@ -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 <Navigate to={buildAuthRoute({ redirect })} replace />;
}
return (
<div className="w-full min-h-full flex flex-row justify-center items-start sm:pl-16">
{sm && (

@ -12,6 +12,7 @@ export {
buildAuthRoute,
getSafeRedirectPath,
isPublicRoute,
shouldGatePrivateInstance,
} from "./redirect-safety";
/**

@ -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}/`);
}

@ -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);
});
});

Loading…
Cancel
Save