All checks were successful
check / check (push) Successful in 6s
## Summary Adds username and hostname support to sessions, enabling standard IRC hostmask format (`nick!user@host`) for WHOIS, WHO, and future `+b` ban matching. closes #81 ## Changes ### Schema (`001_initial.sql`) - Added `username TEXT NOT NULL DEFAULT ''` and `hostname TEXT NOT NULL DEFAULT ''` columns to the `sessions` table ### Database layer (`internal/db/`) - `CreateSession` now accepts `username` and `hostname` parameters; username defaults to nick if empty - `RegisterUser` now accepts `username` and `hostname` parameters - New `SessionHostInfo` type and `GetSessionHostInfo` query to retrieve username/hostname for a session - `MemberInfo` now includes `Username` and `Hostname` fields - `ChannelMembers` query updated to return username/hostname - New `FormatHostmask(nick, username, hostname)` helper that produces `nick!user@host` format - New `Hostmask()` method on `MemberInfo` ### Handler layer (`internal/handlers/`) - Session creation (`POST /api/v1/session`) accepts optional `username` field; resolves hostname via reverse DNS of connecting client IP (respects `X-Forwarded-For` and `X-Real-IP` headers) - Registration (`POST /api/v1/register`) accepts optional `username` field with the same hostname resolution - Username validation regex: `^[a-zA-Z0-9_\-\[\]\\^{}|` + "\`" + `]{1,32}$` - WHOIS (`311 RPL_WHOISUSER`) now returns the real username and hostname instead of nick/servername - WHO (`352 RPL_WHOREPLY`) now returns the real username and hostname instead of nick/servername - Extracted `validateHashcash` and `resolveUsername` helpers to keep functions under the linter's `funlen` limit - Extracted `executeRegister` helper for the same reason - Reverse DNS uses `(*net.Resolver).LookupAddr` with a 3-second timeout context ### Tests - `TestCreateSessionWithUserHost` — verifies username/hostname are stored and retrievable - `TestCreateSessionDefaultUsername` — verifies empty username defaults to nick - `TestGetSessionHostInfoNotFound` — verifies error on nonexistent session - `TestFormatHostmask` — verifies `nick!user@host` formatting - `TestFormatHostmaskDefaults` — verifies fallback when username/hostname empty - `TestMemberInfoHostmask` — verifies `Hostmask()` method on `MemberInfo` - `TestChannelMembersIncludeUserHost` — verifies `ChannelMembers` returns username/hostname - `TestRegisterUserWithUserHost` — verifies registration stores username/hostname - `TestRegisterUserDefaultUsername` — verifies registration defaults username to nick - `TestWhoisShowsHostInfo` — integration test verifying WHOIS returns the correct username - `TestWhoShowsHostInfo` — integration test verifying WHO returns the correct username - `TestSessionUsernameDefault` — integration test verifying default username in WHOIS - All existing tests updated for new `CreateSession`/`RegisterUser` signatures ### README - New "Hostmask" section documenting the `nick!user@host` format - Updated session creation and registration API docs with the new `username` field - Updated WHOIS/WHO numeric examples to show real username/hostname - Updated sessions schema table with new columns ## Docker build `docker build .` passes cleanly (lint, format, tests, build). Co-authored-by: user <user@Mac.lan guest wan> Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Co-authored-by: clawbot <clawbot@eeqj.de> Reviewed-on: #82 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
178 rivejä
3.6 KiB
Go
178 rivejä
3.6 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
//nolint:gochecknoglobals // var so tests can override via SetBcryptCost
|
|
var bcryptCost = bcrypt.DefaultCost
|
|
|
|
// SetBcryptCost overrides the bcrypt cost.
|
|
// Use bcrypt.MinCost in tests to avoid slow hashing.
|
|
func SetBcryptCost(cost int) { bcryptCost = cost }
|
|
|
|
var errNoPassword = errors.New(
|
|
"account has no password set",
|
|
)
|
|
|
|
// RegisterUser creates a session with a hashed password
|
|
// and returns session ID, client ID, and token.
|
|
func (database *Database) RegisterUser(
|
|
ctx context.Context,
|
|
nick, password, username, hostname, remoteIP string,
|
|
) (int64, int64, string, error) {
|
|
if username == "" {
|
|
username = nick
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword(
|
|
[]byte(password), bcryptCost,
|
|
)
|
|
if err != nil {
|
|
return 0, 0, "", fmt.Errorf(
|
|
"hash password: %w", err,
|
|
)
|
|
}
|
|
|
|
sessionUUID := uuid.New().String()
|
|
clientUUID := uuid.New().String()
|
|
|
|
token, err := generateToken()
|
|
if err != nil {
|
|
return 0, 0, "", err
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
transaction, err := database.conn.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, 0, "", fmt.Errorf(
|
|
"begin tx: %w", err,
|
|
)
|
|
}
|
|
|
|
res, err := transaction.ExecContext(ctx,
|
|
`INSERT INTO sessions
|
|
(uuid, nick, username, hostname, ip,
|
|
password_hash, created_at, last_seen)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
sessionUUID, nick, username, hostname,
|
|
remoteIP, string(hash), now, now)
|
|
if err != nil {
|
|
_ = transaction.Rollback()
|
|
|
|
return 0, 0, "", fmt.Errorf(
|
|
"create session: %w", err,
|
|
)
|
|
}
|
|
|
|
sessionID, _ := res.LastInsertId()
|
|
|
|
tokenHash := hashToken(token)
|
|
|
|
clientRes, err := transaction.ExecContext(ctx,
|
|
`INSERT INTO clients
|
|
(uuid, session_id, token, ip, hostname,
|
|
created_at, last_seen)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
clientUUID, sessionID, tokenHash,
|
|
remoteIP, hostname, now, now)
|
|
if err != nil {
|
|
_ = transaction.Rollback()
|
|
|
|
return 0, 0, "", fmt.Errorf(
|
|
"create client: %w", err,
|
|
)
|
|
}
|
|
|
|
clientID, _ := clientRes.LastInsertId()
|
|
|
|
err = transaction.Commit()
|
|
if err != nil {
|
|
return 0, 0, "", fmt.Errorf(
|
|
"commit registration: %w", err,
|
|
)
|
|
}
|
|
|
|
return sessionID, clientID, token, nil
|
|
}
|
|
|
|
// LoginUser verifies a nick/password and creates a new
|
|
// client token.
|
|
func (database *Database) LoginUser(
|
|
ctx context.Context,
|
|
nick, password, remoteIP, hostname string,
|
|
) (int64, int64, string, error) {
|
|
var (
|
|
sessionID int64
|
|
passwordHash string
|
|
)
|
|
|
|
err := database.conn.QueryRowContext(
|
|
ctx,
|
|
`SELECT id, password_hash
|
|
FROM sessions WHERE nick = ?`,
|
|
nick,
|
|
).Scan(&sessionID, &passwordHash)
|
|
if err != nil {
|
|
return 0, 0, "", fmt.Errorf(
|
|
"get session for login: %w", err,
|
|
)
|
|
}
|
|
|
|
if passwordHash == "" {
|
|
return 0, 0, "", fmt.Errorf(
|
|
"login: %w", errNoPassword,
|
|
)
|
|
}
|
|
|
|
err = bcrypt.CompareHashAndPassword(
|
|
[]byte(passwordHash), []byte(password),
|
|
)
|
|
if err != nil {
|
|
return 0, 0, "", fmt.Errorf(
|
|
"verify password: %w", err,
|
|
)
|
|
}
|
|
|
|
clientUUID := uuid.New().String()
|
|
|
|
token, err := generateToken()
|
|
if err != nil {
|
|
return 0, 0, "", err
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
tokenHash := hashToken(token)
|
|
|
|
res, err := database.conn.ExecContext(ctx,
|
|
`INSERT INTO clients
|
|
(uuid, session_id, token, ip, hostname,
|
|
created_at, last_seen)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
clientUUID, sessionID, tokenHash,
|
|
remoteIP, hostname, now, now)
|
|
if err != nil {
|
|
return 0, 0, "", fmt.Errorf(
|
|
"create login client: %w", err,
|
|
)
|
|
}
|
|
|
|
clientID, _ := res.LastInsertId()
|
|
|
|
_, _ = database.conn.ExecContext(
|
|
ctx,
|
|
"UPDATE sessions SET last_seen = ? WHERE id = ?",
|
|
now, sessionID,
|
|
)
|
|
|
|
return sessionID, clientID, token, nil
|
|
}
|