mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-15 21:13:00 +08:00
383 lines
14 KiB
Go
383 lines
14 KiB
Go
package auth
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/matthewlu070111/smart-srun/core/internal/domain"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/protocol/srun"
|
|
)
|
|
|
|
// T05 -- a successful login is Accepted, and only Accepted.
|
|
//
|
|
// The gateway saying "ok" is the gateway's claim about itself. Calling that
|
|
// VerifiedSelf would report a user as logged in without ever having asked whose
|
|
// session is on the line, which is the mistake spec 04 spends most of its words
|
|
// preventing.
|
|
func TestASuccessfulLoginIsAcceptedNotVerified(t *testing.T) {
|
|
gateway := newFakeGateway(t)
|
|
transaction := transactionFor(t, gateway)
|
|
|
|
challenge, err := transaction.Challenge(t.Context(), "2020123456")
|
|
if err != nil {
|
|
t.Fatalf("Challenge: %v", err)
|
|
}
|
|
if challenge.Token != "token-abcdef" {
|
|
t.Fatalf("token = %q", challenge.Token)
|
|
}
|
|
|
|
result, err := transaction.Login(t.Context(),
|
|
Credentials{Username: "2020123456", Password: "pw12345678"},
|
|
Shape{}, challenge)
|
|
if err != nil {
|
|
t.Fatalf("Login: %v", err)
|
|
}
|
|
if result.State != domain.AuthAccepted {
|
|
t.Errorf("state = %s, want Accepted", result.State)
|
|
}
|
|
if result.State == domain.AuthVerifiedSelf {
|
|
t.Error("the gateway's own answer was treated as a verified identity")
|
|
}
|
|
}
|
|
|
|
// T05 -- the login carries every parameter spec 04 lists, with the wire
|
|
// password as the marked digest and the checksum over the bare one.
|
|
func TestTheLoginCarriesTheProtocolsParameters(t *testing.T) {
|
|
gateway := newFakeGateway(t)
|
|
transaction := transactionFor(t, gateway)
|
|
|
|
challenge, _ := transaction.Challenge(t.Context(), "2020123456")
|
|
if _, err := transaction.Login(t.Context(),
|
|
Credentials{Username: "2020123456", Password: "pw12345678"},
|
|
Shape{}, challenge); err != nil {
|
|
t.Fatalf("Login: %v", err)
|
|
}
|
|
|
|
query := gateway.lastQuery(t, portalPath)
|
|
for _, name := range []string{"action", "username", "password", "ac_id",
|
|
"ip", "chksum", "info", "n", "type", "os", "name", "double_stack",
|
|
"callback", "_"} {
|
|
if query.Get(name) == "" {
|
|
t.Errorf("the login is missing %s", name)
|
|
}
|
|
}
|
|
if got := query.Get("action"); got != "login" {
|
|
t.Errorf("action = %q", got)
|
|
}
|
|
|
|
digest := srun.HMACMD5Hex("token-abcdef", "pw12345678")
|
|
if want := srun.WirePasswordPrefix + digest; query.Get("password") != want {
|
|
t.Errorf("password = %q, want the {MD5}-marked digest", query.Get("password"))
|
|
}
|
|
// The checksum is over the bare digest. Feeding it the marked wire field is
|
|
// an easy mistake and produces a signature the gateway rejects.
|
|
want := srun.Checksum("token-abcdef", "2020123456", digest, "12",
|
|
"10.0.0.77", "200", "1", query.Get("info"))
|
|
if query.Get("chksum") != want {
|
|
t.Error("the checksum was not computed over the bare digest")
|
|
}
|
|
if strings.Contains(query.Get("info"), "{{") {
|
|
t.Errorf("the info prefix was applied twice: %q", query.Get("info"))
|
|
}
|
|
}
|
|
|
|
// T05 -- a refused login is Rejected, and the gateway's own words are kept for
|
|
// the log without becoming this program's explanation.
|
|
func TestARefusedLoginIsRejected(t *testing.T) {
|
|
gateway := newFakeGateway(t)
|
|
gateway.configure(func(g *fakeGateway) {
|
|
g.loginBody = `{"error":"login_error","error_msg":"E2531: invalid password"}`
|
|
})
|
|
transaction := transactionFor(t, gateway)
|
|
|
|
challenge, _ := transaction.Challenge(t.Context(), "2020123456")
|
|
result, err := transaction.Login(t.Context(),
|
|
Credentials{Username: "2020123456", Password: "wrong"}, Shape{}, challenge)
|
|
if err != nil {
|
|
t.Fatalf("a refusal is an answer, not a transport failure: %v", err)
|
|
}
|
|
if result.State != domain.AuthRejected {
|
|
t.Errorf("state = %s, want Rejected", result.State)
|
|
}
|
|
if result.GatewayCode != "login_error" {
|
|
t.Errorf("the gateway's code was lost: %q", result.GatewayCode)
|
|
}
|
|
if result.AlreadyOnline {
|
|
t.Error("a refusal was read as already-online")
|
|
}
|
|
}
|
|
|
|
// T05 -- already-online is neither success nor failure. It is a question about
|
|
// whose session that is, and answering it optimistically is how a user gets
|
|
// told they are logged in on somebody else's.
|
|
func TestAlreadyOnlineIsNotTreatedAsSuccess(t *testing.T) {
|
|
for _, body := range []string{
|
|
`{"error":"ip_already_online_error"}`,
|
|
`{"error":"login_error","error_msg":"ip already online"}`,
|
|
`{"error":"already_online"}`,
|
|
} {
|
|
gateway := newFakeGateway(t)
|
|
gateway.configure(func(g *fakeGateway) { g.loginBody = body })
|
|
transaction := transactionFor(t, gateway)
|
|
|
|
challenge, _ := transaction.Challenge(t.Context(), "2020123456")
|
|
result, err := transaction.Login(t.Context(),
|
|
Credentials{Username: "2020123456", Password: "pw"}, Shape{}, challenge)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", body, err)
|
|
}
|
|
if !result.AlreadyOnline {
|
|
t.Errorf("%s was not recognised as already-online", body)
|
|
}
|
|
if result.State == domain.AuthAccepted {
|
|
t.Errorf("%s was treated as a successful login", body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// T05 -- the online query is what turns an acceptance into an identity, and it
|
|
// distinguishes this account from anybody else's.
|
|
func TestTheOnlineQueryDistinguishesSelfFromOther(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
expected string
|
|
want domain.AuthState
|
|
}{
|
|
{"this account", `{"user_name":"2020123456","online_ip":"10.0.0.77"}`,
|
|
"2020123456", domain.AuthVerifiedSelf},
|
|
{"the same account with its suffix",
|
|
`{"user_name":"2020123456@cmcc","online_ip":"10.0.0.77"}`,
|
|
"2020123456", domain.AuthVerifiedSelf},
|
|
{"the same account, suffix expected",
|
|
`{"user_name":"2020123456","online_ip":"10.0.0.77"}`,
|
|
"2020123456@cmcc", domain.AuthVerifiedSelf},
|
|
{"somebody else", `{"user_name":"2020999999","online_ip":"10.0.0.77"}`,
|
|
"2020123456", domain.AuthVerifiedOther},
|
|
// Offline rather than Unknown, and the difference is the point. The
|
|
// gateway said nobody is here, which is evidence; Unknown is for having
|
|
// asked nothing. A logout may only report success against the first.
|
|
{"nobody", `{"error":"not_online_error"}`,
|
|
"2020123456", domain.AuthOffline},
|
|
{"an empty identity", `{"user_name":"","online_ip":""}`,
|
|
"2020123456", domain.AuthOffline},
|
|
// Two realms both stated and not the same account. Bare names agreeing
|
|
// is not enough: these are two carriers' subscribers who happen to share
|
|
// a student number.
|
|
{"a different realm", `{"user_name":"2020123456@ctcc","online_ip":"10.0.0.77"}`,
|
|
"2020123456@cmcc", domain.AuthVerifiedOther},
|
|
// The realm reported as its own field takes part the same way.
|
|
{"a realm in its own field",
|
|
`{"user_name":"2020123456","domain":"ctcc","online_ip":"10.0.0.77"}`,
|
|
"2020123456@cmcc", domain.AuthVerifiedOther},
|
|
}
|
|
|
|
for _, testCase := range cases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
gateway := newFakeGateway(t)
|
|
gateway.configure(func(g *fakeGateway) { g.onlineBody = testCase.body })
|
|
transaction := transactionFor(t, gateway)
|
|
|
|
identity, err := transaction.Online(t.Context(), testCase.expected)
|
|
if err != nil {
|
|
t.Fatalf("Online: %v", err)
|
|
}
|
|
if identity.State() != testCase.want {
|
|
t.Errorf("state = %s, want %s (reported %q, expected %q)",
|
|
identity.State(), testCase.want, identity.Username,
|
|
testCase.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Two genuinely different accounts must never compare equal, or this program
|
|
// would claim somebody else's session as its own.
|
|
func TestTwoDifferentAccountsNeverMatch(t *testing.T) {
|
|
cases := [][2]string{
|
|
{"2020123456", "2020123457"},
|
|
{"2020123456@cmcc", "2020999999@cmcc"},
|
|
{"2020123456", ""},
|
|
{"", "2020123456"},
|
|
{"", ""},
|
|
{"alice", "alicia"},
|
|
}
|
|
for _, pair := range cases {
|
|
if sameAccount(pair[0], pair[1]) {
|
|
t.Errorf("%q and %q were treated as the same account", pair[0], pair[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
// T06 -- the signed logout goes to rad_user_dm, with seconds, and the same
|
|
// value goes into the signature.
|
|
//
|
|
// The endpoint is half the contract and this test used to assert the other
|
|
// half only. Spec 04 names rad_user_dm and the baseline posts there
|
|
// (srun_auth.logout takes rad_user_dm_api); sending the signed form to
|
|
// srun_portal means a gateway that implements this endpoint never receives the
|
|
// unbind, so the session stays up while this program reports it gone. Signing
|
|
// with milliseconds produces a signature the gateway refuses without saying
|
|
// why, which is the other half.
|
|
func TestTheSignedLogoutGoesToRadUserDMWithSeconds(t *testing.T) {
|
|
gateway := newFakeGateway(t)
|
|
transaction := transactionFor(t, gateway)
|
|
|
|
if _, err := transaction.Logout(t.Context(), "2020123456", "10.0.0.77"); err != nil {
|
|
t.Fatalf("Logout: %v", err)
|
|
}
|
|
|
|
for _, seen := range gateway.seen() {
|
|
if seen.path == portalPath {
|
|
t.Fatalf("the signed logout was sent to %s", portalPath)
|
|
}
|
|
}
|
|
query := gateway.lastQuery(t, logoutPath)
|
|
if got := query.Get("time"); got != "1700000000" {
|
|
t.Errorf("time = %q, want the fixed clock's seconds", got)
|
|
}
|
|
if got := query.Get("unbind"); got != "1" {
|
|
t.Errorf("unbind = %q, want 1", got)
|
|
}
|
|
if got := query.Get("username"); got != "2020123456" {
|
|
t.Errorf("username = %q", got)
|
|
}
|
|
want := srun.LogoutSign(1700000000, "2020123456", "10.0.0.77")
|
|
if query.Get("sign") != want {
|
|
t.Errorf("sign = %q, want %q", query.Get("sign"), want)
|
|
}
|
|
// The baseline's build_logout_params carries callback, time, unbind, ip,
|
|
// username and sign -- and nothing else. action and ac_id belong to the
|
|
// portal form of the request; carrying them here would be a third shape
|
|
// that neither the baseline nor the spec describes.
|
|
for _, unwanted := range []string{"action", "ac_id"} {
|
|
if query.Has(unwanted) {
|
|
t.Errorf("the signed logout carries %q, which belongs to the portal form",
|
|
unwanted)
|
|
}
|
|
}
|
|
}
|
|
|
|
// T06 -- logging out needs to know who. An empty account is a programming
|
|
// mistake, not a request to log out whoever happens to be there.
|
|
func TestLoggingOutWithNoAccountIsRefused(t *testing.T) {
|
|
gateway := newFakeGateway(t)
|
|
transaction := transactionFor(t, gateway)
|
|
|
|
if _, err := transaction.Logout(t.Context(), "", "10.0.0.77"); err == nil {
|
|
t.Fatal("a logout with no account was sent")
|
|
}
|
|
if len(gateway.seen()) != 0 {
|
|
t.Errorf("a request was made anyway: %+v", gateway.seen())
|
|
}
|
|
}
|
|
|
|
// A portal intercepting the request answers with a page, and that has its own
|
|
// code so the interface can say what is happening rather than "malformed".
|
|
func TestAnHTMLAnswerIsReportedAsAPortalInterception(t *testing.T) {
|
|
gateway := newFakeGateway(t)
|
|
gateway.configure(func(g *fakeGateway) {
|
|
g.rawBody = "<!DOCTYPE html><html><body>Please sign in</body></html>"
|
|
})
|
|
transaction := transactionFor(t, gateway)
|
|
|
|
_, err := transaction.Challenge(t.Context(), "2020123456")
|
|
if err == nil {
|
|
t.Fatal("a web page was accepted as a challenge")
|
|
}
|
|
if code := codeOf(t, err); code != domain.CodePortalHTMLResponse {
|
|
t.Errorf("code = %s, want PortalHTMLResponse", code)
|
|
}
|
|
}
|
|
|
|
// A login cannot be built without a challenge: the token is what the whole
|
|
// request is signed with, and sending an unsigned one wastes an attempt and
|
|
// teaches the user nothing.
|
|
func TestALoginWithoutAChallengeIsRefusedBeforeSending(t *testing.T) {
|
|
gateway := newFakeGateway(t)
|
|
transaction := transactionFor(t, gateway)
|
|
|
|
_, err := transaction.Login(t.Context(),
|
|
Credentials{Username: "2020123456", Password: "pw"}, Shape{}, Challenge{})
|
|
if err == nil {
|
|
t.Fatal("a login was built with no token")
|
|
}
|
|
if len(gateway.seen()) != 0 {
|
|
t.Errorf("a request was sent anyway: %+v", gateway.seen())
|
|
}
|
|
}
|
|
|
|
// The account's own protocol shape is used, and the defaults fill only what it
|
|
// left unset.
|
|
func TestTheAccountsShapeOverridesTheDefaults(t *testing.T) {
|
|
gateway := newFakeGateway(t)
|
|
transaction := transactionFor(t, gateway)
|
|
|
|
challenge, _ := transaction.Challenge(t.Context(), "2020123456")
|
|
if _, err := transaction.Login(t.Context(),
|
|
Credentials{Username: "2020123456", Password: "pw"},
|
|
Shape{N: "201", Type: "3", DoubleStack: true}, challenge); err != nil {
|
|
t.Fatalf("Login: %v", err)
|
|
}
|
|
|
|
query := gateway.lastQuery(t, portalPath)
|
|
if query.Get("n") != "201" || query.Get("type") != "3" {
|
|
t.Errorf("n/type = %q/%q, want the account's own values",
|
|
query.Get("n"), query.Get("type"))
|
|
}
|
|
if query.Get("double_stack") != "1" {
|
|
t.Errorf("double_stack = %q, want the protocol's 1", query.Get("double_stack"))
|
|
}
|
|
// Unset fields still get the protocol's defaults.
|
|
if query.Get("os") != "Windows 10" || query.Get("name") != "Windows" {
|
|
t.Errorf("os/name = %q/%q", query.Get("os"), query.Get("name"))
|
|
}
|
|
}
|
|
|
|
// double_stack is "0" or "1" on the wire, never "true".
|
|
func TestDoubleStackIsTheProtocolsSpelling(t *testing.T) {
|
|
if boolWire(true) != "1" || boolWire(false) != "0" {
|
|
t.Errorf("boolWire gives %q/%q, want 1/0", boolWire(true), boolWire(false))
|
|
}
|
|
}
|
|
|
|
// The gateway's view of the client address is used when it gives one, because
|
|
// behind NAT it differs from the bound source and it is what the session will
|
|
// be recorded against.
|
|
func TestTheGatewaysClientAddressIsPreferredForTheLogin(t *testing.T) {
|
|
gateway := newFakeGateway(t)
|
|
gateway.configure(func(g *fakeGateway) { g.clientIP = "203.0.113.9" })
|
|
transaction := transactionFor(t, gateway)
|
|
|
|
challenge, _ := transaction.Challenge(t.Context(), "2020123456")
|
|
if challenge.ClientIP != "203.0.113.9" {
|
|
t.Fatalf("client_ip = %q", challenge.ClientIP)
|
|
}
|
|
if _, err := transaction.Login(t.Context(),
|
|
Credentials{Username: "2020123456", Password: "pw"}, Shape{},
|
|
challenge); err != nil {
|
|
t.Fatalf("Login: %v", err)
|
|
}
|
|
if got := gateway.lastQuery(t, portalPath).Get("ip"); got != "203.0.113.9" {
|
|
t.Errorf("ip = %q, want the address the gateway reported", got)
|
|
}
|
|
}
|
|
|
|
// With no reported address the bound source is used: it is what the packets
|
|
// actually carry, and inventing something else would be worse.
|
|
func TestWithoutAReportedAddressTheBoundSourceIsUsed(t *testing.T) {
|
|
gateway := newFakeGateway(t)
|
|
gateway.configure(func(g *fakeGateway) { g.clientIP = "" })
|
|
transaction := transactionFor(t, gateway)
|
|
|
|
challenge, _ := transaction.Challenge(t.Context(), "2020123456")
|
|
if _, err := transaction.Login(t.Context(),
|
|
Credentials{Username: "2020123456", Password: "pw"}, Shape{},
|
|
challenge); err != nil {
|
|
t.Fatalf("Login: %v", err)
|
|
}
|
|
if got := gateway.lastQuery(t, portalPath).Get("ip"); got != "10.0.0.77" {
|
|
t.Errorf("ip = %q, want the bound source address", got)
|
|
}
|
|
}
|