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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+14
-17
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user