classes.RevokeUserAccess stamps users.tokens_valid_after (whole seconds) and revokes the users' personal API tokens; DeactivateUsers and BanUsers call it for every selected user. Restore and unban revive nothing, and a deactivated user who signs in again gets a fresh working token. POST /refresh now loads the token's subject after bouncer.Refresh and refuses a missing, deactivated or banned user and a token issued before tokens_valid_after with the existing 401 body; a refused refresh stamps no last_seen. Suspension is deliberately not checked.
196 lines
7.4 KiB
Go
196 lines
7.4 KiB
Go
package user
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
|
usermiddleware "git.golem15.com/golem15/sm-user-plugin/middleware"
|
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
|
)
|
|
|
|
const refusedRefreshBody = `{"error":"Could not refresh token","msg":"User not found"}`
|
|
|
|
// offboardingCreds gives a seeded user an older session JWT (issued two
|
|
// seconds ago, so a revocation cutoff in this second cuts it) and a usr_
|
|
// personal API token.
|
|
func offboardingCreds(t *testing.T, e *adminEnv, user *models.User) (jwt, apiSecret string) {
|
|
t.Helper()
|
|
now := time.Now()
|
|
jwt = craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "off-"+adminStamp(), now.Add(-2*time.Second), now.Add(time.Hour))
|
|
secret, token, err := classes.MintAPIToken("usr_", user.ID, "offboarding", []string{"read"}, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := e.gdb.Create(token).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return jwt, secret
|
|
}
|
|
|
|
// apiTokenWorks authenticates secret through the user.api_token guard.
|
|
func apiTokenWorks(t *testing.T, e *adminEnv, secret string) bool {
|
|
t.Helper()
|
|
guard := &usermiddleware.APITokenGuard{App: e.app}
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/example", nil)
|
|
req.Header.Set("Authorization", "Bearer "+secret)
|
|
principal, _, err := guard.AuthenticateCredential(req)
|
|
return err == nil && principal != nil
|
|
}
|
|
|
|
func (e *adminEnv) fetchCode(t *testing.T, token string) int {
|
|
t.Helper()
|
|
return e.call(t, http.MethodGet, "/_user/api/v1/fetch", token, nil).Code
|
|
}
|
|
|
|
func (e *adminEnv) refresh(t *testing.T, token string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
return e.call(t, http.MethodPost, "/_user/api/v1/refresh", token, nil)
|
|
}
|
|
|
|
func assertRefreshRefused(t *testing.T, rec *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != refusedRefreshBody {
|
|
t.Fatalf("refresh = %d %s, want 401 %s", rec.Code, rec.Body.String(), refusedRefreshBody)
|
|
}
|
|
}
|
|
|
|
// TestOffboarding: deactivate and ban end every earlier session JWT and
|
|
// personal API token. A deactivated user reactivates by signing in and gets
|
|
// a working token; a banned one is refused. Restore and unban revive no
|
|
// token.
|
|
func TestOffboarding(t *testing.T) {
|
|
e := newAdminEnv(t)
|
|
admin := e.admin(t, controllers.PermissionAccessUsers)
|
|
|
|
t.Run("deactivate", func(t *testing.T) {
|
|
user := e.seedUser(t, "off-deactivate", nil)
|
|
jwt, secret := offboardingCreds(t, e, user)
|
|
if e.fetchCode(t, jwt) != http.StatusOK || !apiTokenWorks(t, e, secret) {
|
|
t.Fatal("credentials do not work before the deactivation")
|
|
}
|
|
e.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/deactivate"), admin, map[string]any{"ids": []uint{user.ID}})
|
|
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
|
|
t.Fatalf("fetch after deactivate = %d", code)
|
|
}
|
|
if apiTokenWorks(t, e, secret) {
|
|
t.Fatal("API token works after deactivate")
|
|
}
|
|
assertRefreshRefused(t, e.refresh(t, jwt))
|
|
|
|
// Signing in right away (the same second allowed) restores the
|
|
// account, mails the reactivation and returns a working token.
|
|
code, fresh := e.userLogin(t, user.Email, "correct-horse")
|
|
if code != http.StatusOK || fresh == "" {
|
|
t.Fatalf("login after deactivate = %d", code)
|
|
}
|
|
if row := e.reload(t, user.ID); row == nil || row.DeletedAt.Valid {
|
|
t.Fatal("login did not restore the account")
|
|
}
|
|
if len(e.mails(user.Email)) != 1 {
|
|
t.Fatalf("reactivate mails = %d", len(e.mails(user.Email)))
|
|
}
|
|
if code := e.fetchCode(t, fresh); code != http.StatusOK {
|
|
t.Fatalf("fetch with the fresh token = %d", code)
|
|
}
|
|
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
|
|
t.Fatalf("old JWT after the restore = %d", code)
|
|
}
|
|
if apiTokenWorks(t, e, secret) {
|
|
t.Fatal("old API token works after the restore")
|
|
}
|
|
})
|
|
|
|
t.Run("ban", func(t *testing.T) {
|
|
user := e.seedUser(t, "off-ban", nil)
|
|
jwt, secret := offboardingCreds(t, e, user)
|
|
e.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/ban"), admin, map[string]any{"ids": []uint{user.ID}})
|
|
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
|
|
t.Fatalf("fetch after ban = %d", code)
|
|
}
|
|
if apiTokenWorks(t, e, secret) {
|
|
t.Fatal("API token works after ban")
|
|
}
|
|
assertRefreshRefused(t, e.refresh(t, jwt))
|
|
rec := e.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"})
|
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
|
|
t.Fatalf("banned login = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
e.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/unban"), admin, map[string]any{"ids": []uint{user.ID}})
|
|
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
|
|
t.Fatalf("old JWT after unban = %d", code)
|
|
}
|
|
if apiTokenWorks(t, e, secret) {
|
|
t.Fatal("old API token works after unban")
|
|
}
|
|
code, fresh := e.userLogin(t, user.Email, "correct-horse")
|
|
if code != http.StatusOK || e.fetchCode(t, fresh) != http.StatusOK {
|
|
t.Fatalf("login after unban = %d", code)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestRefreshSubjectChecks: /refresh loads the subject even without a
|
|
// revocation stamp. Deactivated, banned and cut-off subjects are refused
|
|
// with the token-failure body and stamp no last_seen; a suspended user still
|
|
// refreshes, and a normal refresh stamps last_seen.
|
|
func TestRefreshSubjectChecks(t *testing.T) {
|
|
e := newAdminEnv(t)
|
|
token := func(t *testing.T, user *models.User, iat time.Time) string {
|
|
t.Helper()
|
|
return craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "refresh-"+adminStamp(), iat, iat.Add(time.Hour))
|
|
}
|
|
lastSeen := func(t *testing.T, user *models.User) *time.Time {
|
|
t.Helper()
|
|
return e.reload(t, user.ID).LastSeen
|
|
}
|
|
|
|
t.Run("deactivated", func(t *testing.T) {
|
|
user := e.seedUser(t, "refresh-deactivated", nil)
|
|
if err := e.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, user.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertRefreshRefused(t, e.refresh(t, token(t, user, time.Now())))
|
|
if lastSeen(t, user) != nil {
|
|
t.Fatal("a refused refresh stamped last_seen")
|
|
}
|
|
})
|
|
t.Run("banned", func(t *testing.T) {
|
|
user := e.seedUser(t, "refresh-banned", nil)
|
|
e.seedThrottle(t, user, "", true, nil)
|
|
assertRefreshRefused(t, e.refresh(t, token(t, user, time.Now())))
|
|
if lastSeen(t, user) != nil {
|
|
t.Fatal("a refused refresh stamped last_seen")
|
|
}
|
|
})
|
|
t.Run("issued before tokens_valid_after", func(t *testing.T) {
|
|
user := e.seedUser(t, "refresh-cutoff", nil)
|
|
if err := e.gdb.Exec(`UPDATE users SET tokens_valid_after = ? WHERE id = ?`, time.Now().Truncate(time.Second), user.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertRefreshRefused(t, e.refresh(t, token(t, user, time.Now().Add(-10*time.Second))))
|
|
})
|
|
t.Run("suspended", func(t *testing.T) {
|
|
user := e.seedUser(t, "refresh-suspended", nil)
|
|
now := time.Now()
|
|
e.seedThrottle(t, user, "203.0.113.50", false, &now)
|
|
if rec := e.refresh(t, token(t, user, time.Now())); rec.Code != http.StatusOK {
|
|
t.Fatalf("suspended refresh = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
t.Run("normal", func(t *testing.T) {
|
|
user := e.seedUser(t, "refresh-normal", nil)
|
|
if rec := e.refresh(t, token(t, user, time.Now())); rec.Code != http.StatusOK {
|
|
t.Fatalf("refresh = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if lastSeen(t, user) == nil {
|
|
t.Fatal("refresh did not stamp last_seen")
|
|
}
|
|
})
|
|
}
|