mirror of https://github.com/usememos/memos
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
parent
1794d0dc51
commit
d1cef7a9ab
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
Loading…
Reference in New Issue