feat(auth): implement user and device authentication
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/transport/authcommon"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestLoginPageIssuesCSRFAndRendersSafeNext(t *testing.T) {
|
||||
router, _ := newAuthTestRouter(t, &fakeAdminSessions{})
|
||||
|
||||
response := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/login?next=%2Ftasks%2Fnew",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
|
||||
}
|
||||
cookie := csrfCookie(t, response)
|
||||
body := response.Body.String()
|
||||
for _, expected := range []string{
|
||||
`name="csrf_token" value="` + cookie.Value + `"`,
|
||||
`name="next" value="/tasks/new"`,
|
||||
`autocomplete="username"`,
|
||||
`autocomplete="current-password"`,
|
||||
`data-password-toggle`,
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("login body missing %q", expected)
|
||||
}
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
}
|
||||
|
||||
func TestLoginRejectsInvalidCredentialsWithoutLeakingPassword(t *testing.T) {
|
||||
sessions := &fakeAdminSessions{loginErr: ErrInvalidCredentials}
|
||||
router, _ := newAuthTestRouter(t, sessions)
|
||||
csrf := loginCSRF(t, router)
|
||||
form := url.Values{
|
||||
"csrf_token": {csrf.Value},
|
||||
"username": {"admin"},
|
||||
"password": {"private-password-value"},
|
||||
"next": {"/tasks"},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/login",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(csrf)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusUnauthorized ||
|
||||
!strings.Contains(response.Body.String(), "账号或密码不正确") {
|
||||
t.Fatalf("status/body = %d / %s", response.Code, response.Body)
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "private-password-value") {
|
||||
t.Fatal("login response contains submitted password")
|
||||
}
|
||||
if sessions.loginInput.Username != "admin" ||
|
||||
sessions.loginInput.Password != "private-password-value" {
|
||||
t.Fatalf("login input = %+v", sessions.loginInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitBlocksBeforePasswordVerification(t *testing.T) {
|
||||
sessions := &fakeAdminSessions{loginErr: ErrInvalidCredentials}
|
||||
limiter, err := authcommon.NewAttemptLimiter(1, time.Minute, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAttemptLimiter() error = %v", err)
|
||||
}
|
||||
router, _ := newAuthTestRouterWithLimiter(t, sessions, limiter)
|
||||
csrf := loginCSRF(t, router)
|
||||
form := url.Values{
|
||||
"csrf_token": {csrf.Value},
|
||||
"username": {"admin"},
|
||||
"password": {"private-password-value"},
|
||||
"next": {"/tasks"},
|
||||
}
|
||||
requestLogin := func() *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/login",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set(
|
||||
"Content-Type",
|
||||
"application/x-www-form-urlencoded",
|
||||
)
|
||||
request.AddCookie(csrf)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
if response := requestLogin(); response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("first status = %d", response.Code)
|
||||
}
|
||||
response := requestLogin()
|
||||
if response.Code != http.StatusTooManyRequests ||
|
||||
response.Header().Get("Retry-After") == "" ||
|
||||
!strings.Contains(response.Body.String(), "尝试次数过多") ||
|
||||
sessions.loginCalls != 1 {
|
||||
t.Fatalf(
|
||||
"second status/retry/calls = %d / %q / %d",
|
||||
response.Code,
|
||||
response.Header().Get("Retry-After"),
|
||||
sessions.loginCalls,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRotatesSessionAndCSRFThenUsesSafeRedirect(t *testing.T) {
|
||||
expiresAt := time.Now().UTC().Add(8 * time.Hour)
|
||||
sessions := &fakeAdminSessions{
|
||||
loginResult: AdminLoginResult{
|
||||
Token: mustToken(t),
|
||||
ExpiresAt: expiresAt,
|
||||
},
|
||||
}
|
||||
router, _ := newAuthTestRouter(t, sessions)
|
||||
csrf := loginCSRF(t, router)
|
||||
form := url.Values{
|
||||
"csrf_token": {csrf.Value},
|
||||
"username": {"admin"},
|
||||
"password": {"valid-password"},
|
||||
"next": {"https://attacker.invalid/private"},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/login",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(csrf)
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: AdminSessionCookieName,
|
||||
Value: mustToken(t),
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") != "/tasks" {
|
||||
t.Fatalf(
|
||||
"status/location = %d / %q",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
var sessionCookie, rotatedCSRF *http.Cookie
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
switch cookie.Name {
|
||||
case AdminSessionCookieName:
|
||||
sessionCookie = cookie
|
||||
case csrfCookieName:
|
||||
rotatedCSRF = cookie
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil ||
|
||||
sessionCookie.Value != sessions.loginResult.Token ||
|
||||
!sessionCookie.HttpOnly ||
|
||||
sessionCookie.SameSite != http.SameSiteLaxMode ||
|
||||
sessionCookie.Path != "/" ||
|
||||
sessionCookie.Secure {
|
||||
t.Fatalf("session cookie = %+v", sessionCookie)
|
||||
}
|
||||
if rotatedCSRF == nil || rotatedCSRF.Value == csrf.Value {
|
||||
t.Fatalf("CSRF was not rotated: %+v", rotatedCSRF)
|
||||
}
|
||||
if sessions.logoutToken == "" ||
|
||||
sessions.logoutToken == sessions.loginResult.Token {
|
||||
t.Fatalf(
|
||||
"previous session was not revoked: %q",
|
||||
sessions.logoutToken,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSessionCookieIsSecureForTLSRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/secure-cookie", func(ctx *gin.Context) {
|
||||
setAdminSessionCookie(ctx, AdminLoginResult{
|
||||
Token: mustToken(t),
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
})
|
||||
ctx.Status(http.StatusNoContent)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodGet, "/secure-cookie", nil)
|
||||
request.TLS = &tls.ConnectionState{}
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
var sessionCookie *http.Cookie
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == AdminSessionCookieName {
|
||||
sessionCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil || !sessionCookie.Secure {
|
||||
t.Fatalf("TLS session cookie = %+v", sessionCookie)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginFailsClosedWhenPreviousSessionCannotBeRevoked(
|
||||
t *testing.T,
|
||||
) {
|
||||
newToken := mustToken(t)
|
||||
oldToken := mustToken(t)
|
||||
for oldToken == newToken {
|
||||
oldToken = mustToken(t)
|
||||
}
|
||||
sessions := &fakeAdminSessions{
|
||||
loginResult: AdminLoginResult{
|
||||
Token: newToken,
|
||||
ExpiresAt: time.Now().UTC().Add(8 * time.Hour),
|
||||
},
|
||||
logoutErr: ErrUnavailable,
|
||||
}
|
||||
router, _ := newAuthTestRouter(t, sessions)
|
||||
csrf := loginCSRF(t, router)
|
||||
form := url.Values{
|
||||
"csrf_token": {csrf.Value},
|
||||
"username": {"admin"},
|
||||
"password": {"valid-password"},
|
||||
"next": {"/tasks"},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/login",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(csrf)
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: AdminSessionCookieName,
|
||||
Value: oldToken,
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusServiceUnavailable ||
|
||||
sessions.logoutToken != newToken {
|
||||
t.Fatalf(
|
||||
"status/last revoked token = %d / %q",
|
||||
response.Code,
|
||||
sessions.logoutToken,
|
||||
)
|
||||
}
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == AdminSessionCookieName &&
|
||||
cookie.Value == newToken {
|
||||
t.Fatal("new session cookie was returned after revoke failure")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeNextRejectsExternalAndAmbiguousPaths(t *testing.T) {
|
||||
tests := []string{
|
||||
"https://attacker.invalid/tasks",
|
||||
"//attacker.invalid/tasks",
|
||||
`/tasks\redirect`,
|
||||
"/tasks/../admin",
|
||||
"/healthz",
|
||||
"tasks",
|
||||
}
|
||||
for _, candidate := range tests {
|
||||
if actual := safeNext(candidate); actual != "/tasks" {
|
||||
t.Fatalf("safeNext(%q) = %q", candidate, actual)
|
||||
}
|
||||
}
|
||||
if actual := safeNext("/tasks/item?id=1"); actual != "/tasks/item?id=1" {
|
||||
t.Fatalf("safeNext(valid) = %q", actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
|
||||
sessions := &fakeAdminSessions{}
|
||||
router, _ := newAuthTestRouter(t, sessions)
|
||||
csrf := loginCSRF(t, router)
|
||||
sessionValue := mustToken(t)
|
||||
form := url.Values{"csrf_token": {csrf.Value}}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/logout",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(csrf)
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: AdminSessionCookieName,
|
||||
Value: sessionValue,
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") != "/login" ||
|
||||
sessions.logoutToken != sessionValue {
|
||||
t.Fatalf(
|
||||
"status/location/token = %d / %q / %q",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
sessions.logoutToken,
|
||||
)
|
||||
}
|
||||
foundCleared := false
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == AdminSessionCookieName && cookie.MaxAge < 0 {
|
||||
foundCleared = true
|
||||
}
|
||||
}
|
||||
if !foundCleared {
|
||||
t.Fatal("logout did not clear the admin session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutRejectsWrongCSRFWithoutRevoking(t *testing.T) {
|
||||
sessions := &fakeAdminSessions{}
|
||||
router, _ := newAuthTestRouter(t, sessions)
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/logout",
|
||||
strings.NewReader("csrf_token=wrong"),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: mustToken(t),
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusForbidden || sessions.logoutToken != "" {
|
||||
t.Fatalf(
|
||||
"status/logout token = %d / %q",
|
||||
response.Code,
|
||||
sessions.logoutToken,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAdminSessions struct {
|
||||
loginInput AdminLoginInput
|
||||
loginResult AdminLoginResult
|
||||
loginErr error
|
||||
loginCalls int
|
||||
logoutToken string
|
||||
logoutErr error
|
||||
}
|
||||
|
||||
func (service *fakeAdminSessions) LoginAdmin(
|
||||
_ context.Context,
|
||||
input AdminLoginInput,
|
||||
) (AdminLoginResult, error) {
|
||||
service.loginCalls++
|
||||
service.loginInput = input
|
||||
return service.loginResult, service.loginErr
|
||||
}
|
||||
|
||||
func (service *fakeAdminSessions) LogoutAdmin(
|
||||
_ context.Context,
|
||||
token string,
|
||||
) error {
|
||||
service.logoutToken = token
|
||||
return service.logoutErr
|
||||
}
|
||||
|
||||
func newAuthTestRouter(
|
||||
t *testing.T,
|
||||
sessions AdminSessionService,
|
||||
) (http.Handler, *AuthHandler) {
|
||||
t.Helper()
|
||||
limiter, err := authcommon.NewAttemptLimiter(
|
||||
100,
|
||||
time.Minute,
|
||||
100,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAttemptLimiter() error = %v", err)
|
||||
}
|
||||
return newAuthTestRouterWithLimiter(t, sessions, limiter)
|
||||
}
|
||||
|
||||
func newAuthTestRouterWithLimiter(
|
||||
t *testing.T,
|
||||
sessions AdminSessionService,
|
||||
limiter authcommon.AttemptLimiter,
|
||||
) (http.Handler, *AuthHandler) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRenderer() error = %v", err)
|
||||
}
|
||||
handler, err := NewAuthHandler(sessions, renderer, limiter)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthHandler() error = %v", err)
|
||||
}
|
||||
router := gin.New()
|
||||
handler.RegisterPublic(router)
|
||||
handler.RegisterProtected(router)
|
||||
return router, handler
|
||||
}
|
||||
|
||||
func loginCSRF(t *testing.T, router http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
response := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/login",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
return csrfCookie(t, response)
|
||||
}
|
||||
|
||||
var _ AdminSessionService = (*fakeAdminSessions)(nil)
|
||||
Reference in New Issue
Block a user