精简后的项目:仅保留 api/ui 源码、文档、构建依赖
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql/handler/transport"
|
||||
"github.com/photoview/photoview/api/dataloader"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrUnauthorized = errors.New("unauthorized")
|
||||
var bearerRegex = regexp.MustCompile("^(?i)Bearer ([a-zA-Z0-9]{24})$")
|
||||
|
||||
const INVALID_AUTH_TOKEN = "invalid authorization token"
|
||||
const INTERNAL_SERVER_ERROR = "internal server error"
|
||||
|
||||
// A private key for context that only this package can access. This is important
|
||||
// to prevent collisions between different context uses
|
||||
var userCtxKey = &contextKey{"user"}
|
||||
|
||||
type contextKey struct {
|
||||
name string
|
||||
}
|
||||
|
||||
// Middleware decodes the share session cookie and packs the session into context
|
||||
func Middleware(db *gorm.DB) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if tokenCookie, err := r.Cookie("auth-token"); err == nil {
|
||||
loaders := dataloader.For(r.Context())
|
||||
if loaders == nil {
|
||||
log.Error(r.Context(), "Dataloader not available in HTTP context")
|
||||
http.Error(w, INTERNAL_SERVER_ERROR, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := loaders.UserFromAccessToken.Load(tokenCookie.Value)
|
||||
// Check for dataloader errors (database failures, etc.)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error loading user from token", "error", err)
|
||||
http.Error(w, INVALID_AUTH_TOKEN, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// If user is nil, the token doesn't exist or is invalid
|
||||
if user == nil {
|
||||
log.Error(r.Context(), "Token not found in database")
|
||||
http.Error(w, INVALID_AUTH_TOKEN, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// put it in context
|
||||
ctx := AddUserToContext(r.Context(), user)
|
||||
|
||||
// and call the next with our new context
|
||||
r = r.WithContext(ctx)
|
||||
} else {
|
||||
log.Info(r.Context(), "Did not find auth-token cookie")
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func AddUserToContext(ctx context.Context, user *models.User) context.Context {
|
||||
return context.WithValue(ctx, userCtxKey, user)
|
||||
}
|
||||
|
||||
func TokenFromBearer(bearer *string) (*string, error) {
|
||||
matches := bearerRegex.FindStringSubmatch(*bearer)
|
||||
if len(matches) != 2 {
|
||||
return nil, errors.New("invalid bearer format")
|
||||
}
|
||||
|
||||
token := matches[1]
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// UserFromContext finds the user from the context. REQUIRES Middleware to have run.
|
||||
func UserFromContext(ctx context.Context) *models.User {
|
||||
raw, _ := ctx.Value(userCtxKey).(*models.User)
|
||||
return raw
|
||||
}
|
||||
|
||||
func AuthWebsocketInit() func(context.Context, transport.InitPayload) (context.Context, *transport.InitPayload, error) {
|
||||
return func(ctx context.Context, initPayload transport.InitPayload) (context.Context, *transport.InitPayload, error) {
|
||||
|
||||
bearer, exists := initPayload["Authorization"].(string)
|
||||
if !exists {
|
||||
return ctx, nil, nil
|
||||
}
|
||||
|
||||
token, err := TokenFromBearer(&bearer)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Invalid bearer format (websocket)", "error", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
loaders := dataloader.For(ctx)
|
||||
if loaders == nil {
|
||||
log.Error(ctx, "Dataloader not available in websocket context")
|
||||
return nil, nil, errors.New(INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
user, err := loaders.UserFromAccessToken.Load(*token)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading user from token (websocket)", "error", err)
|
||||
return nil, nil, errors.New(INVALID_AUTH_TOKEN)
|
||||
}
|
||||
|
||||
// Check if token exists in database
|
||||
if user == nil {
|
||||
log.Error(ctx, "Token not found in database (websocket)")
|
||||
return nil, nil, errors.New(INVALID_AUTH_TOKEN)
|
||||
}
|
||||
|
||||
// put it in context
|
||||
userCtx := context.WithValue(ctx, userCtxKey, user)
|
||||
|
||||
// and return it so the resolvers can see it
|
||||
// Return nil for the InitPayload acknowledgment (no custom ack payload needed)
|
||||
return userCtx, nil, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql/handler/transport"
|
||||
"github.com/photoview/photoview/api/dataloader"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.UnitTestRun(m)
|
||||
}
|
||||
|
||||
func TestTokenFromBearer(t *testing.T) {
|
||||
|
||||
testsValues := []struct {
|
||||
name string
|
||||
bearer string
|
||||
out string
|
||||
valid bool
|
||||
}{
|
||||
{"Valid bearer", "Bearer ZY9YfxFa3TapSAD37XUBFryo", "ZY9YfxFa3TapSAD37XUBFryo", true},
|
||||
{"Case insensitive bearer", "bEaReR ZY9YfxFa3TapSAD37XUBFryo", "ZY9YfxFa3TapSAD37XUBFryo", true},
|
||||
{"Missing bearer start", "ZY9YfxFa3TapSAD37XUBFryo", "", false},
|
||||
{"Empty input", "", "", false},
|
||||
{"Invalid token value", "Bearer THIS_IS_INVALID", "", false},
|
||||
}
|
||||
|
||||
for _, test := range testsValues {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
token, err := auth.TokenFromBearer(&test.bearer)
|
||||
if test.valid {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, token)
|
||||
assert.Equal(t, test.out, *token)
|
||||
} else {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, token)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthWebsocketInit(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
// Create test user and token
|
||||
password := "test_password"
|
||||
user, err := models.RegisterUser(db, "testuser", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
token, err := user.GenerateAccessToken(db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
initPayload transport.InitPayload
|
||||
expectError bool
|
||||
expectUser bool
|
||||
expectNilCtx bool
|
||||
}{
|
||||
{
|
||||
name: "Valid authorization",
|
||||
initPayload: transport.InitPayload{"Authorization": "Bearer " + token.Value},
|
||||
expectError: false,
|
||||
expectUser: true,
|
||||
},
|
||||
{
|
||||
name: "Missing authorization",
|
||||
initPayload: transport.InitPayload{},
|
||||
expectError: false,
|
||||
expectUser: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid bearer format",
|
||||
initPayload: transport.InitPayload{"Authorization": "InvalidFormat"},
|
||||
expectError: true,
|
||||
expectNilCtx: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid token",
|
||||
initPayload: transport.InitPayload{"Authorization": "Bearer INVALID_TOKEN_123456"},
|
||||
expectError: true,
|
||||
expectNilCtx: true,
|
||||
},
|
||||
{
|
||||
name: "Empty token",
|
||||
initPayload: transport.InitPayload{"Authorization": "Bearer "},
|
||||
expectError: true,
|
||||
expectNilCtx: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
initFunc := auth.AuthWebsocketInit()
|
||||
ctx := context.Background()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
var contextWithLoaders context.Context
|
||||
handler := dataloader.Middleware(db)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
contextWithLoaders = r.Context()
|
||||
}))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, req)
|
||||
|
||||
resultCtx, ackPayload, err := initFunc(contextWithLoaders, tc.initPayload)
|
||||
|
||||
if tc.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
if tc.expectNilCtx {
|
||||
assert.Nil(t, resultCtx)
|
||||
} else {
|
||||
assert.NotNil(t, resultCtx)
|
||||
}
|
||||
|
||||
// Verify InitPayload acknowledgment is always nil (as per PR implementation)
|
||||
assert.Nil(t, ackPayload)
|
||||
|
||||
if tc.expectUser {
|
||||
retrievedUser := auth.UserFromContext(resultCtx)
|
||||
assert.NotNil(t, retrievedUser)
|
||||
assert.Equal(t, user.ID, retrievedUser.ID)
|
||||
assert.Equal(t, "testuser", retrievedUser.Username)
|
||||
} else if !tc.expectNilCtx {
|
||||
retrievedUser := auth.UserFromContext(resultCtx)
|
||||
assert.Nil(t, retrievedUser)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddleware(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
// Create test user and token
|
||||
password := "test_password"
|
||||
user, err := models.RegisterUser(db, "testuser", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
token, err := user.GenerateAccessToken(db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
cookieValue string
|
||||
setCookie bool
|
||||
expectStatus int
|
||||
expectUser bool
|
||||
}{
|
||||
{
|
||||
name: "Valid token cookie",
|
||||
cookieValue: token.Value,
|
||||
setCookie: true,
|
||||
expectStatus: 200,
|
||||
expectUser: true,
|
||||
},
|
||||
{
|
||||
name: "No cookie",
|
||||
setCookie: false,
|
||||
expectStatus: 200,
|
||||
expectUser: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid token",
|
||||
cookieValue: "INVALID_TOKEN",
|
||||
setCookie: true,
|
||||
expectStatus: 401,
|
||||
expectUser: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/graphql", nil)
|
||||
if tc.setCookie {
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "auth-token",
|
||||
Value: tc.cookieValue,
|
||||
})
|
||||
}
|
||||
|
||||
var capturedContext context.Context
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedContext = r.Context()
|
||||
})
|
||||
|
||||
authHandler := auth.Middleware(db)(handler)
|
||||
fullHandler := dataloader.Middleware(db)(authHandler)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
fullHandler.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, recorder.Code)
|
||||
|
||||
if tc.expectUser {
|
||||
retrievedUser := auth.UserFromContext(capturedContext)
|
||||
assert.NotNil(t, retrievedUser)
|
||||
assert.Equal(t, user.ID, retrievedUser.ID)
|
||||
} else if recorder.Code == 200 {
|
||||
// Handler was called, verify no user in context
|
||||
retrievedUser := auth.UserFromContext(capturedContext)
|
||||
assert.Nil(t, retrievedUser)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextUserOperations(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
password := "test"
|
||||
user, err := models.RegisterUser(db, "testuser", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("AddUserToContext and UserFromContext", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Initially no user
|
||||
retrieved := auth.UserFromContext(ctx)
|
||||
assert.Nil(t, retrieved)
|
||||
|
||||
// Add user
|
||||
ctxWithUser := auth.AddUserToContext(ctx, user)
|
||||
|
||||
// Retrieve user
|
||||
retrieved = auth.UserFromContext(ctxWithUser)
|
||||
assert.NotNil(t, retrieved)
|
||||
assert.Equal(t, user.ID, retrieved.ID)
|
||||
assert.Equal(t, "testuser", retrieved.Username)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user