bcc6b45515
Implements #23 per ADR-0002. - Discord authorization code grant (identify + guilds.members.read), form-encoded token exchange - Guild membership gate via the single-guild endpoint; optional DISCORD_REQUIRED_ROLE (empty default) - Owner Discord ID is the only identity allowed to sign in - Sessions are DB rows with opaque random ids; cookie carries only the id; expiry enforced; delete = revoke - HMAC session signing, derived key, and WEB_PASSWORD removed; no replacement signing secret - Login rate limiting preserved on the callback - Full flow tested through the real router against a local Discord stub (DISCORD_API_BASE) - Env: DISCORD_CLIENT_ID/_CLIENT_SECRET/_GUILD_ID/_REQUIRED_ROLE/_API_BASE/_REDIRECT_URI; docs updated go test ./... passes. Reviewed-on: #31 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
189 lines
4.9 KiB
Go
189 lines
4.9 KiB
Go
package session
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNewID(t *testing.T) {
|
|
a := NewID()
|
|
b := NewID()
|
|
if a == b {
|
|
t.Fatal("NewID returned the same value twice")
|
|
}
|
|
if len(a) != 64 { // 32 random bytes, hex
|
|
t.Fatalf("NewID() length = %d, want 64", len(a))
|
|
}
|
|
for _, r := range a {
|
|
if !strings.ContainsRune("0123456789abcdef", r) {
|
|
t.Fatalf("NewID() = %q, want hex", a)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetSessionCookieAttributes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
tls bool
|
|
forwarded string
|
|
wantSecure bool
|
|
}{
|
|
{"plain http dev", false, "", false},
|
|
{"direct tls", true, "", true},
|
|
{"behind https proxy", false, "https", true},
|
|
{"behind http proxy", false, "http", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodPost, "/", nil)
|
|
if tc.tls {
|
|
r.TLS = &tls.ConnectionState{}
|
|
}
|
|
if tc.forwarded != "" {
|
|
r.Header.Set("X-Forwarded-Proto", tc.forwarded)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
SetCookie(rr, r, "abc123")
|
|
|
|
cookies := rr.Result().Cookies()
|
|
if len(cookies) != 1 {
|
|
t.Fatalf("got %d cookies, want 1", len(cookies))
|
|
}
|
|
c := cookies[0]
|
|
if c.Name != CookieName {
|
|
t.Fatalf("cookie name = %q, want %q", c.Name, CookieName)
|
|
}
|
|
if c.Value != "abc123" {
|
|
t.Fatalf("cookie value = %q, want the session id verbatim", c.Value)
|
|
}
|
|
if !c.HttpOnly {
|
|
t.Fatal("cookie HttpOnly = false, want true")
|
|
}
|
|
if c.SameSite != http.SameSiteLaxMode {
|
|
t.Fatalf("cookie SameSite = %v, want Lax", c.SameSite)
|
|
}
|
|
if c.Path != "/" {
|
|
t.Fatalf("cookie Path = %q, want /", c.Path)
|
|
}
|
|
if c.Secure != tc.wantSecure {
|
|
t.Fatalf("cookie Secure = %v, want %v", c.Secure, tc.wantSecure)
|
|
}
|
|
if c.MaxAge != int(SessionTTL/time.Second) {
|
|
t.Fatalf("cookie MaxAge = %d, want %d", c.MaxAge, int(SessionTTL/time.Second))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClearSessionCookie(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
|
rr := httptest.NewRecorder()
|
|
ClearCookie(rr, r)
|
|
|
|
cookies := rr.Result().Cookies()
|
|
if len(cookies) != 1 {
|
|
t.Fatalf("got %d cookies, want 1", len(cookies))
|
|
}
|
|
if cookies[0].MaxAge >= 0 {
|
|
t.Fatalf("cleared cookie MaxAge = %d, want negative", cookies[0].MaxAge)
|
|
}
|
|
}
|
|
|
|
func TestClientIP(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
remoteAddr string
|
|
xff []string
|
|
want string
|
|
}{
|
|
{"no header falls back to remote addr", "203.0.113.9:5555", nil, "203.0.113.9"},
|
|
{"single proxy hop", "10.0.0.1:5555", []string{"203.0.113.9"}, "203.0.113.9"},
|
|
{
|
|
// The client sent "1.2.3.4" itself; Traefik appended the address it
|
|
// actually saw. Only the rightmost entry is trustworthy.
|
|
name: "spoofed left entry is ignored",
|
|
remoteAddr: "10.0.0.1:5555",
|
|
xff: []string{"1.2.3.4, 203.0.113.9"},
|
|
want: "203.0.113.9",
|
|
},
|
|
{
|
|
name: "spoofed separate header line is ignored",
|
|
remoteAddr: "10.0.0.1:5555",
|
|
xff: []string{"1.2.3.4", "203.0.113.9"},
|
|
want: "203.0.113.9",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodPost, "/", nil)
|
|
r.RemoteAddr = tc.remoteAddr
|
|
for _, v := range tc.xff {
|
|
r.Header.Add("X-Forwarded-For", v)
|
|
}
|
|
if got := ClientIP(r); got != tc.want {
|
|
t.Fatalf("ClientIP() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) {
|
|
l := NewLoginLimiter()
|
|
now := time.Now()
|
|
for i := 0; i < MaxFailures; i++ {
|
|
if wait := l.RetryAfter("1.2.3.4", now); wait != 0 {
|
|
t.Fatalf("blocked after %d failures, want block only after %d", i, MaxFailures)
|
|
}
|
|
l.Fail("1.2.3.4", now)
|
|
}
|
|
wait := l.RetryAfter("1.2.3.4", now)
|
|
if wait <= 0 {
|
|
t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, MaxFailures)
|
|
}
|
|
if wait > Window {
|
|
t.Fatalf("retryAfter = %v, want <= %v", wait, Window)
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterWindowExpires(t *testing.T) {
|
|
l := NewLoginLimiter()
|
|
start := time.Now()
|
|
for i := 0; i < MaxFailures; i++ {
|
|
l.Fail("1.2.3.4", start)
|
|
}
|
|
if l.RetryAfter("1.2.3.4", start) == 0 {
|
|
t.Fatal("expected block immediately after the failures")
|
|
}
|
|
later := start.Add(Window + time.Second)
|
|
if wait := l.RetryAfter("1.2.3.4", later); wait != 0 {
|
|
t.Fatalf("retryAfter = %v once the window passed, want 0", wait)
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterResetClearsCounter(t *testing.T) {
|
|
l := NewLoginLimiter()
|
|
now := time.Now()
|
|
for i := 0; i < MaxFailures; i++ {
|
|
l.Fail("1.2.3.4", now)
|
|
}
|
|
l.Reset("1.2.3.4")
|
|
if wait := l.RetryAfter("1.2.3.4", now); wait != 0 {
|
|
t.Fatalf("retryAfter = %v after reset, want 0", wait)
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterIsPerIP(t *testing.T) {
|
|
l := NewLoginLimiter()
|
|
now := time.Now()
|
|
for i := 0; i < MaxFailures; i++ {
|
|
l.Fail("1.2.3.4", now)
|
|
}
|
|
if wait := l.RetryAfter("5.6.7.8", now); wait != 0 {
|
|
t.Fatalf("retryAfter for a different IP = %v, want 0", wait)
|
|
}
|
|
}
|