| | 1 | = Use-case 0002 Implementation - Log in = |
| | 2 | |
| | 3 | '''Initiating actor:''' Visitor |
| | 4 | |
| | 5 | '''Other actors:''' — |
| | 6 | |
| | 7 | A registered user authenticates with a username and password so that the system |
| | 8 | treats all following actions as actions of that Trader. The system looks the user up |
| | 9 | by username and compares the stored password hash with the SHA-256 hash of the |
| | 10 | entered password. An unknown username and a wrong password give the same answer, |
| | 11 | `Invalid credentials.`, so the system does not reveal which usernames exist. After a |
| | 12 | successful login the user's id and username are kept in the in-process session and |
| | 13 | the authenticated (Trader) menu is shown, from which all other Trader use-cases |
| | 14 | start. |
| | 15 | |
| | 16 | Original use-case description (P3): [wiki:UseCase0002]. |
| | 17 | Implementation: `server/auth.go`, functions `Login` and `authenticate` (password hash |
| | 18 | by `hashPassword`; the code is shown at the end of this page). |
| | 19 | |
| | 20 | == Scenario == |
| | 21 | |
| | 22 | 1. '''Visitor''' chooses `[2] Login` in the anonymous menu (types `2`). |
| | 23 | 2. '''System''' prints `-- Login --` and asks for `Username:` and then `Password:`. |
| | 24 | |
| | 25 | The screenshot shows steps 1–2: option `2` is chosen and the `Username:` prompt |
| | 26 | is waiting for input. |
| | 27 | |
| | 28 | [[Image(uc0002_1_login.png)]] |
| | 29 | |
| | 30 | 3. '''Visitor''' enters the username and the password. (If either is empty, the |
| | 31 | system prints `Username and password are required.` without accessing the |
| | 32 | database.) |
| | 33 | 4. '''System''' looks up the user (`$1` = entered username): |
| | 34 | |
| | 35 | {{{ |
| | 36 | SELECT id, password_hash FROM users WHERE username = $1 |
| | 37 | }}} |
| | 38 | |
| | 39 | 5. If no row is returned (`sql.ErrNoRows` in Go), the '''System''' responds |
| | 40 | `Invalid credentials.` and the scenario ends. |
| | 41 | 6. If a row is returned, the '''System''' compares the returned `password_hash` with |
| | 42 | `hashPassword(entered password)` (hex-encoded SHA-256, computed in Go). On a |
| | 43 | mismatch it responds `Invalid credentials.` and the scenario ends. |
| | 44 | |
| | 45 | The screenshot shows this failure path with an existing user and a wrong |
| | 46 | password: `alice` / `wrongpass`. The query from step 4 finds alice's row, the |
| | 47 | hash comparison of step 6 fails, and the system prints `Invalid credentials.` and |
| | 48 | returns to the anonymous menu. (An unknown username — step 5 — prints exactly the |
| | 49 | same message.) |
| | 50 | |
| | 51 | [[Image(uc0002_5_6_invalid.png)]] |
| | 52 | |
| | 53 | 7. On a match, the '''System''' stores the returned `id` and the username in the |
| | 54 | session (`s.UserID`, `s.Username`), prints `Login successful.` and displays the |
| | 55 | authenticated menu headed `--- Logged in as alice ---`. |
| | 56 | |
| | 57 | The screenshot shows steps 3–7 of the second, successful attempt with the seed |
| | 58 | credentials `alice` / `test123` (the first, rejected attempt is still visible at |
| | 59 | the top of the window). |
| | 60 | |
| | 61 | [[Image(uc0002_7_success.png)]] |
| | 62 | |
| | 63 | The query runs on the `project` schema (the connection sets |
| | 64 | `search_path=project,public`), so `users` means `project.users`. |
| | 65 | |
| | 66 | === Alternate flow 4a (P3) — lookup combined with the live balance === |
| | 67 | |
| | 68 | P3 describes an optional variant that checks the password in SQL and returns the |
| | 69 | balances in the same query. The P4 prototype does '''not''' use it: login always uses |
| | 70 | the query from step 4 with the hash comparison in Go, and the balances are read |
| | 71 | separately when the Trader asks for them (`[1] View balance`, see |
| | 72 | [wiki:UseCase0003Implementation UseCase0003]). |
| | 73 | |
| | 74 | == Seed credentials == |
| | 75 | |
| | 76 | State after `./eduberza -init` (`server/db/data_load.sql`): |
| | 77 | |
| | 78 | ||= Username =||= Password =||= Available balance =||= Invested balance =||= Holdings =|| |
| | 79 | || `alice` || `test123` || 8250.00 USD || 1750.00 USD || 0.5 ETH || |
| | 80 | || `bob` || `test123` || 5000.00 USD || 0.00 USD || — || |
| | 81 | || `charlie` || `test123` || 2500.00 USD || 0.00 USD || — || |
| | 82 | |
| | 83 | == How to reproduce == |
| | 84 | |
| | 85 | {{{ |
| | 86 | ./eduberza -init |
| | 87 | ./eduberza |
| | 88 | # [2] Login: alice / wrongpass -> Invalid credentials. |
| | 89 | # [2] Login: alice / test123 -> Login successful. (authenticated menu) |
| | 90 | }}} |
| | 91 | |
| | 92 | The screenshots come from one real run of exactly these inputs. |
| | 93 | |
| | 94 | == Source code == |
| | 95 | |
| | 96 | `server/auth.go` — `hashPassword`, `Login` and `authenticate`: |
| | 97 | |
| | 98 | {{{ |
| | 99 | func hashPassword(pw string) string { |
| | 100 | sum := sha256.Sum256([]byte(pw)) |
| | 101 | return hex.EncodeToString(sum[:]) |
| | 102 | } |
| | 103 | }}} |
| | 104 | |
| | 105 | {{{ |
| | 106 | // Login - UC0002 |
| | 107 | func Login(s *Session) { |
| | 108 | fmt.Println("\n-- Login --") |
| | 109 | username := prompt("Username: ") |
| | 110 | pw := prompt("Password: ") |
| | 111 | if username == "" || pw == "" { |
| | 112 | fmt.Println("Username and password are required.") |
| | 113 | return |
| | 114 | } |
| | 115 | |
| | 116 | id, err := authenticate(username, pw) |
| | 117 | if err != nil { |
| | 118 | if errors.Is(err, errInvalidCreds) { |
| | 119 | fmt.Println("Invalid credentials.") |
| | 120 | return |
| | 121 | } |
| | 122 | fmt.Println("Login error:", err) |
| | 123 | return |
| | 124 | } |
| | 125 | s.UserID = id |
| | 126 | s.Username = username |
| | 127 | fmt.Println("Login successful.") |
| | 128 | } |
| | 129 | |
| | 130 | var errInvalidCreds = errors.New("invalid credentials") |
| | 131 | |
| | 132 | func authenticate(username, pw string) (string, error) { |
| | 133 | var id, stored string |
| | 134 | err := db.DB.QueryRow( |
| | 135 | `SELECT id, password_hash FROM users WHERE username = $1`, |
| | 136 | username, |
| | 137 | ).Scan(&id, &stored) |
| | 138 | if err == sql.ErrNoRows { |
| | 139 | return "", errInvalidCreds |
| | 140 | } |
| | 141 | if err != nil { |
| | 142 | return "", err |
| | 143 | } |
| | 144 | if stored != hashPassword(pw) { |
| | 145 | return "", errInvalidCreds |
| | 146 | } |
| | 147 | return id, nil |
| | 148 | } |
| | 149 | }}} |