Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions config/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ func NewConfig(options ...ConfigOption) *models.Config {
DisabledPaths: []string{},
Session: models.SessionConfig{
CookieName: "authula.session_token",
ExpiresIn: time.Hour * 24 * 7, // 7 days by default
UpdateAge: time.Hour * 24, // 24 hours update interval
ExpiresIn: time.Hour * 24 * 7,
UpdateAge: time.Hour * 24,
Secure: false,
HttpOnly: true,
SameSite: "lax",
CookieMaxAge: 24 * time.Hour,
CookieMaxAge: time.Hour * 24 * 7,
AutoCleanup: false,
CleanupInterval: time.Minute,
MaxSessionsPerUser: 5,
Expand Down Expand Up @@ -184,8 +184,11 @@ func WithSession(config models.SessionConfig) ConfigOption {
if config.UpdateAge != 0 {
c.Session.UpdateAge = config.UpdateAge
}
if config.CookieMaxAge != 0 {
switch {
case config.CookieMaxAge != 0:
c.Session.CookieMaxAge = config.CookieMaxAge
case config.ExpiresIn != 0:
c.Session.CookieMaxAge = config.ExpiresIn
}
c.Session.Secure = config.Secure
c.Session.HttpOnly = config.HttpOnly
Expand Down
39 changes: 39 additions & 0 deletions config/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"testing"
"time"

"github.com/Authula/authula/models"
)
Expand All @@ -14,6 +15,8 @@ func (l *customLogger) Warn(msg string, args ...any) {}
func (l *customLogger) Error(msg string, args ...any) {}

func TestWithLoggerConfiguresLevelAndCustomLogger(t *testing.T) {
t.Parallel()

logger := &customLogger{}

config := NewConfig(WithLogger(models.LoggerConfig{
Expand All @@ -29,3 +32,39 @@ func TestWithLoggerConfiguresLevelAndCustomLogger(t *testing.T) {
t.Fatal("expected custom logger to be configured")
}
}

func TestWithSessionCookieMaxAge(t *testing.T) {
tests := []struct {
name string
session models.SessionConfig
wantCookieMaxAge time.Duration
}{
{
name: "defaults keep the cookie alive for the whole session lifetime",
session: models.SessionConfig{},
wantCookieMaxAge: time.Hour * 24 * 7,
},
{
name: "follows a custom expires_in when cookie_max_age is unset",
session: models.SessionConfig{ExpiresIn: 2 * time.Hour},
wantCookieMaxAge: 2 * time.Hour,
},
{
name: "explicit cookie_max_age wins over expires_in",
session: models.SessionConfig{ExpiresIn: 2 * time.Hour, CookieMaxAge: time.Hour},
wantCookieMaxAge: time.Hour,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

config := NewConfig(WithSession(tt.session))

if config.Session.CookieMaxAge != tt.wantCookieMaxAge {
t.Fatalf("expected cookie max age %s, got %s", tt.wantCookieMaxAge, config.Session.CookieMaxAge)
}
})
}
}
12 changes: 6 additions & 6 deletions core/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import (
)

type CoreAPI interface {
GetMe(ctx context.Context, userID string) (*types.GetMeResult, error)
SignOut(ctx context.Context, userID string, sessionID *string, signOutAll *bool) (*types.SignOutResult, error)
GetMe(ctx context.Context, userID string, sessionID *string) (*types.GetMeResult, error)
SignOut(ctx context.Context, userID string, currentSessionID *string, requestedSessionID *string, signOutAll *bool) (*types.SignOutResult, error)
}

type coreAPI struct {
Expand All @@ -25,12 +25,12 @@ func NewCoreAPI(logger models.Logger, userService services.UserService, sessionS
}
}

func (api *coreAPI) GetMe(ctx context.Context, userID string) (*types.GetMeResult, error) {
return api.useCases.GetMeUseCase.GetMe(ctx, userID)
func (api *coreAPI) GetMe(ctx context.Context, userID string, sessionID *string) (*types.GetMeResult, error) {
return api.useCases.GetMeUseCase.GetMe(ctx, userID, sessionID)
}

func (api *coreAPI) SignOut(ctx context.Context, userID string, sessionID *string, signOutAll *bool) (*types.SignOutResult, error) {
return api.useCases.SignOutUseCase.SignOut(ctx, userID, sessionID, signOutAll)
func (api *coreAPI) SignOut(ctx context.Context, userID string, currentSessionID *string, requestedSessionID *string, signOutAll *bool) (*types.SignOutResult, error) {
return api.useCases.SignOutUseCase.SignOut(ctx, userID, currentSessionID, requestedSessionID, signOutAll)
}

func BuildUseCases(logger models.Logger, userService services.UserService, sessionService services.SessionService) *usecases.UseCases {
Expand Down
3 changes: 3 additions & 0 deletions core/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ var (

// Token Errors
ErrTokenRequired = errors.New("token is required")

// Session Errors
ErrNoSessionToSignOut = errors.New("no session to sign out: provide session_id or sign_out_all")
)

func HandleError(err error, reqCtx *models.RequestContext) {
Expand Down
7 changes: 6 additions & 1 deletion core/handlers/get_me_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ func (h *GetMeHandler) Handle() http.HandlerFunc {
ctx := r.Context()
reqCtx, _ := models.GetRequestContext(ctx)

result, err := h.UseCase.GetMe(ctx, reqCtx.Actor.ID)
var sessionID *string
if id, ok := reqCtx.Values[models.ContextSessionID.String()].(string); ok && id != "" {
sessionID = &id
}

result, err := h.UseCase.GetMe(ctx, reqCtx.Actor.ID, sessionID)
if err != nil {
reqCtx.SetJSONResponse(http.StatusInternalServerError, map[string]any{
"message": err.Error(),
Expand Down
18 changes: 15 additions & 3 deletions core/handlers/sign_out_handler.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package handlers

import (
"errors"
"net/http"

coreerrors "github.com/Authula/authula/core/errors"
"github.com/Authula/authula/core/types"
"github.com/Authula/authula/core/usecases"
"github.com/Authula/authula/models"
Expand All @@ -20,7 +22,6 @@ func (h *SignOutHandler) Handle() http.HandlerFunc {

var request types.SignOutRequest
if err := util.ParseJSON(r, &request); err != nil {
// If no body is provided, we'll default to using an empty request.
request = types.SignOutRequest{}
}
if err := request.Validate(); err != nil {
Expand All @@ -29,16 +30,27 @@ func (h *SignOutHandler) Handle() http.HandlerFunc {
return
}

result, err := h.UseCase.SignOut(ctx, reqCtx.Actor.ID, request.SessionID, request.SignOutAll)
var currentSessionID *string
if id, ok := reqCtx.Values[models.ContextSessionID.String()].(string); ok && id != "" {
currentSessionID = &id
}

result, err := h.UseCase.SignOut(ctx, reqCtx.Actor.ID, currentSessionID, request.SessionID, request.SignOutAll)
if err != nil {
if errors.Is(err, coreerrors.ErrNotFound) || errors.Is(err, coreerrors.ErrForbidden) || errors.Is(err, coreerrors.ErrNoSessionToSignOut) {
coreerrors.HandleError(err, reqCtx)
return
}
reqCtx.SetJSONResponse(http.StatusInternalServerError, map[string]any{
"message": "failed to sign out",
})
reqCtx.Handled = true
return
}

reqCtx.Values[models.ContextAuthSignOut.String()] = true
if result.ClearedCurrentSession {
reqCtx.Values[models.ContextAuthSignOut.String()] = true
}

reqCtx.SetJSONResponse(http.StatusOK, &types.SignOutResponse{
Message: result.Message,
Expand Down
164 changes: 164 additions & 0 deletions core/handlers/sign_out_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package handlers_test

import (
"errors"
"net/http"
"testing"

"github.com/stretchr/testify/mock"

"github.com/Authula/authula/core/handlers"
"github.com/Authula/authula/core/usecases"
internaltests "github.com/Authula/authula/internal/tests"
"github.com/Authula/authula/models"
)

func TestSignOutHandler(t *testing.T) {
t.Parallel()

const (
userID = "user-1"
currentSessionID = "session-current"
requestedSessionID = "session-requested"
)

tests := []struct {
name string
body []byte
currentSessionID *string
setup func(sessionService *internaltests.MockSessionService)
expectedStatus int
expectedMessage string
expectSignOut bool
}{
{
name: "no body signs out the current session and clears the cookie",
currentSessionID: new(currentSessionID),
setup: func(sessionService *internaltests.MockSessionService) {
sessionService.On("Delete", mock.Anything, currentSessionID).Return(nil).Once()
},
expectedStatus: http.StatusOK,
expectSignOut: true,
},
{
name: "signing out another own session by id does not clear the cookie",
body: []byte(`{"session_id":"` + requestedSessionID + `"}`),
currentSessionID: new(currentSessionID),
setup: func(sessionService *internaltests.MockSessionService) {
sessionService.On("GetByID", mock.Anything, requestedSessionID).Return(&models.Session{ID: requestedSessionID, UserID: userID}, nil).Once()
sessionService.On("Delete", mock.Anything, requestedSessionID).Return(nil).Once()
},
expectedStatus: http.StatusOK,
expectSignOut: false,
},
{
name: "signing out the current session by id clears the cookie",
body: []byte(`{"session_id":"` + currentSessionID + `"}`),
currentSessionID: new(currentSessionID),
setup: func(sessionService *internaltests.MockSessionService) {
sessionService.On("GetByID", mock.Anything, currentSessionID).Return(&models.Session{ID: currentSessionID, UserID: userID}, nil).Once()
sessionService.On("Delete", mock.Anything, currentSessionID).Return(nil).Once()
},
expectedStatus: http.StatusOK,
expectSignOut: true,
},
{
name: "sign out all clears the cookie",
body: []byte(`{"sign_out_all":true}`),
currentSessionID: new(currentSessionID),
setup: func(sessionService *internaltests.MockSessionService) {
sessionService.On("DeleteAllByUserID", mock.Anything, userID).Return(nil).Once()
},
expectedStatus: http.StatusOK,
expectSignOut: true,
},
{
name: "session owned by another user returns forbidden",
body: []byte(`{"session_id":"` + requestedSessionID + `"}`),
currentSessionID: new(currentSessionID),
setup: func(sessionService *internaltests.MockSessionService) {
sessionService.On("GetByID", mock.Anything, requestedSessionID).Return(&models.Session{ID: requestedSessionID, UserID: "user-2"}, nil).Once()
},
expectedStatus: http.StatusForbidden,
expectedMessage: "forbidden",
},
{
name: "unknown session id returns not found",
body: []byte(`{"session_id":"` + requestedSessionID + `"}`),
currentSessionID: new(currentSessionID),
setup: func(sessionService *internaltests.MockSessionService) {
sessionService.On("GetByID", mock.Anything, requestedSessionID).Return((*models.Session)(nil), nil).Once()
},
expectedStatus: http.StatusNotFound,
expectedMessage: "not found",
},
{
name: "unexpected error returns internal server error",
currentSessionID: new(currentSessionID),
setup: func(sessionService *internaltests.MockSessionService) {
sessionService.On("Delete", mock.Anything, currentSessionID).Return(errors.New("db down")).Once()
},
expectedStatus: http.StatusInternalServerError,
expectedMessage: "failed to sign out",
},
{
name: "no current session and no body is rejected",
setup: func(sessionService *internaltests.MockSessionService) {},
expectedStatus: http.StatusBadRequest,
expectedMessage: "no session to sign out: provide session_id or sign_out_all",
},
{
name: "sign out all works without a current session",
body: []byte(`{"sign_out_all":true}`),
setup: func(sessionService *internaltests.MockSessionService) {
sessionService.On("DeleteAllByUserID", mock.Anything, userID).Return(nil).Once()
},
expectedStatus: http.StatusOK,
expectSignOut: true,
},
{
name: "empty session id is rejected",
body: []byte(`{"session_id":" "}`),
setup: func(sessionService *internaltests.MockSessionService) {},
expectedStatus: http.StatusUnprocessableEntity,
expectedMessage: "session_id cannot be empty if provided",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

sessionService := &internaltests.MockSessionService{}
tt.setup(sessionService)

handler := &handlers.SignOutHandler{
UseCase: &usecases.SignOutUseCase{
Logger: &internaltests.MockLogger{},
SessionService: sessionService,
},
}

req, w, reqCtx := internaltests.NewHandlerRequest(t, http.MethodPost, "/sign-out", tt.body, new(userID))
if tt.currentSessionID != nil {
reqCtx.Values[models.ContextSessionID.String()] = *tt.currentSessionID
}

handler.Handle()(w, req)

if reqCtx.ResponseStatus != tt.expectedStatus {
t.Fatalf("expected status %d, got %d", tt.expectedStatus, reqCtx.ResponseStatus)
}
if tt.expectedMessage != "" {
internaltests.AssertErrorMessage(t, reqCtx, tt.expectedStatus, tt.expectedMessage)
}

signedOut, _ := reqCtx.Values[models.ContextAuthSignOut.String()].(bool)
if signedOut != tt.expectSignOut {
t.Fatalf("expected auth.sign_out flag %v, got %v", tt.expectSignOut, signedOut)
}

sessionService.AssertExpectations(t)
})
}
}
2 changes: 1 addition & 1 deletion core/openapi/openapi_docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func RegisterOpenAPIDocs(svc openapi.OpenAPIService) error {
"/sign-out",
openapi.WithOperationID("signOut"),
openapi.WithSummary("Sign out"),
openapi.WithDescription("Signs out the authenticated user. Optionally sign out a specific session or all sessions."),
openapi.WithDescription("Signs out the authenticated user. With no body, revokes the session that authenticated the request (400 if the request carries no session id). Pass session_id to revoke one of your own sessions (404 if it does not exist, 403 if it belongs to another user), or sign_out_all to revoke every session."),
openapi.WithTags("Core"),
openapi.WithRequest(&types.SignOutRequest{}),
openapi.WithResponseStatus(http.StatusOK, &types.SignOutResponse{}),
Expand Down
3 changes: 2 additions & 1 deletion core/types/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ func (req *SignOutRequest) Validate() error {
}

type SignOutResult struct {
Message string
Message string
ClearedCurrentSession bool
}

type SignOutResponse struct {
Expand Down
10 changes: 7 additions & 3 deletions core/usecases/get_me_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type GetMeUseCase struct {
SessionService services.SessionService
}

func (uc *GetMeUseCase) GetMe(ctx context.Context, userID string) (*types.GetMeResult, error) {
func (uc *GetMeUseCase) GetMe(ctx context.Context, userID string, sessionID *string) (*types.GetMeResult, error) {
var (
wg sync.WaitGroup
user *models.User
Expand All @@ -32,9 +32,13 @@ func (uc *GetMeUseCase) GetMe(ctx context.Context, userID string) (*types.GetMeR
})

wg.Go(func() {
session, sessErr = uc.SessionService.GetByUserID(ctx, userID)
if sessionID != nil && *sessionID != "" {
session, sessErr = uc.SessionService.GetByID(ctx, *sessionID)
} else {
session, sessErr = uc.SessionService.GetByUserID(ctx, userID)
}
if sessErr != nil {
uc.Logger.Error("failed to get session by user ID: %v", sessErr)
uc.Logger.Error("failed to get session: %v", sessErr)
}
})

Expand Down
Loading
Loading