diff --git a/db/atlasdb/001_init_db.go b/db/atlasdb/001_init_db.go index 8550fc0..e4c4744 100644 --- a/db/atlasdb/001_init_db.go +++ b/db/atlasdb/001_init_db.go @@ -23,7 +23,7 @@ func up001(ctx context.Context, tx *sqlx.Tx) error { last_server TEXT ) STRICT; `, ` - `, "\n")); err != nil { + `, "\n")); err != nil { return fmt.Errorf("create accounts table: %w", err) } if _, err := tx.ExecContext(ctx, `CREATE INDEX accounts_username_idx ON accounts(username, uid)`); err != nil { diff --git a/db/atlasdb/002_add_clan_tag.go b/db/atlasdb/002_add_clan_tag.go new file mode 100644 index 0000000..7ce95c1 --- /dev/null +++ b/db/atlasdb/002_add_clan_tag.go @@ -0,0 +1,48 @@ +package atlasdb + +import ( + "context" + "fmt" + "strings" + + "github.com/jmoiron/sqlx" +) + +func init() { + migrate(up002, down002) +} + +func up002(ctx context.Context, tx *sqlx.Tx) error { + if _, err := tx.ExecContext(ctx, `ALTER TABLE accounts ADD COLUMN clan_tag TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("add clan_tag column: %w", err) + } + return nil +} + +func down002(ctx context.Context, tx *sqlx.Tx) error { + if _, err := tx.ExecContext(ctx, strings.ReplaceAll(` + CREATE TABLE accounts_new ( + uid TEXT PRIMARY KEY NOT NULL, + username TEXT NOT NULL DEFAULT '' COLLATE NOCASE, + auth_ip TEXT, + auth_token TEXT, + auth_expiry INTEGER, + last_server TEXT + ) STRICT; + `, "\n", "\n")); err != nil { + return fmt.Errorf("create accounts_new table: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO accounts_new(uid, username, auth_ip, auth_token, auth_expiry, last_server) SELECT uid, username, auth_ip, auth_token, auth_expiry, last_server FROM accounts`); err != nil { + return fmt.Errorf("copy accounts data: %w", err) + } + if _, err := tx.ExecContext(ctx, `DROP TABLE accounts`); err != nil { + return fmt.Errorf("drop accounts: %w", err) + } + if _, err := tx.ExecContext(ctx, `ALTER TABLE accounts_new RENAME TO accounts`); err != nil { + return fmt.Errorf("rename accounts_new: %w", err) + } + if _, err := tx.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS accounts_username_idx ON accounts(username, uid)`); err != nil { + return fmt.Errorf("create accounts index: %w", err) + } + return nil +} diff --git a/db/atlasdb/db.go b/db/atlasdb/db.go index 79eb3c0..c694a7d 100644 --- a/db/atlasdb/db.go +++ b/db/atlasdb/db.go @@ -53,6 +53,7 @@ func (db *DB) GetAccount(uid uint64) (*api0.Account, error) { var obj struct { UID uint64 `db:"uid"` Username string `db:"username"` + ClanTag string `db:"clan_tag"` AuthIP string `db:"auth_ip"` AuthToken string `db:"auth_token"` AuthExpiry int64 `db:"auth_expiry"` @@ -82,6 +83,7 @@ func (db *DB) GetAccount(uid uint64) (*api0.Account, error) { return &api0.Account{ UID: obj.UID, Username: obj.Username, + ClanTag: obj.ClanTag, AuthIP: authIP, AuthToken: obj.AuthToken, AuthTokenExpiry: authExpiry, @@ -102,11 +104,12 @@ func (db *DB) SaveAccount(a *api0.Account) error { if _, err := db.x.NamedExec(` INSERT OR REPLACE INTO - accounts ( uid, username, auth_ip, auth_token, auth_expiry, last_server) - VALUES (:uid, :username, :auth_ip, :auth_token, :auth_expiry, :last_server) + accounts ( uid, username, clan_tag, auth_ip, auth_token, auth_expiry, last_server) + VALUES (:uid, :username, :clan_tag, :auth_ip, :auth_token, :auth_expiry, :last_server) `, map[string]any{ "uid": a.UID, "username": a.Username, + "clan_tag": a.ClanTag, "auth_ip": authIP, "auth_token": a.AuthToken, "auth_expiry": authExpiry, diff --git a/pkg/api/api0/accounts.go b/pkg/api/api0/accounts.go index 215d0a3..2af0479 100644 --- a/pkg/api/api0/accounts.go +++ b/pkg/api/api0/accounts.go @@ -5,6 +5,10 @@ import ( "net/http" "net/netip" "strconv" + "strings" + "time" + "unicode/utf8" + "regexp" "github.com/r2northstar/atlas/pkg/pdata" "github.com/rs/zerolog/hlog" @@ -301,3 +305,105 @@ func (h *Handler) handleAccountsGetUsername(w http.ResponseWriter, r *http.Reque "matches": []string{username}, // yes, this may be an empty string if we don't know what it is }) } + +func (h *Handler) handleAccountsSetClanTag(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodOptions && r.Method != http.MethodPost { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + if r.Method == http.MethodOptions { + w.Header().Set("Allow", "OPTIONS, POST") + w.WriteHeader(http.StatusNoContent) + return + } + + uidQ := r.URL.Query().Get("id") + if uidQ == "" { + respFail(w, r, http.StatusBadRequest, ErrorCode_BAD_REQUEST.MessageObjf("id param is required")) + return + } + uid, err := strconv.ParseUint(uidQ, 10, 64) + if err != nil { + respFail(w, r, http.StatusNotFound, ErrorCode_PLAYER_NOT_FOUND.MessageObj()) + return + } + + playerToken := r.URL.Query().Get("playerToken") + + acct, err := h.AccountStorage.GetAccount(uid) + if err != nil { + respFail(w, r, http.StatusInternalServerError, ErrorCode_INTERNAL_SERVER_ERROR.MessageObj()) + return + } + if acct == nil { + respFail(w, r, http.StatusNotFound, ErrorCode_PLAYER_NOT_FOUND.MessageObj()) + return + } + + if !h.InsecureDevNoCheckPlayerAuth { + if playerToken != acct.AuthToken || !time.Now().Before(acct.AuthTokenExpiry) { + respFail(w, r, http.StatusUnauthorized, ErrorCode_INVALID_MASTERSERVER_TOKEN.MessageObj()) + return + } + } + + tag := r.URL.Query().Get("tag") + tag = strings.TrimSpace(tag) + if h.CleanBadWords != nil { + tag = h.CleanBadWords(tag) + } + + if tag != "" { + if utf8.RuneCountInString(tag) > 4 { + respFail(w, r, http.StatusBadRequest, ErrorCode_BAD_REQUEST.MessageObjf("tag must be at most 4 characters")) + return + } + // allow ASCII letters, digits and CJK (Han) characters only + ok, _ := regexp.MatchString(`^[A-Za-z0-9\p{Han}]+$`, tag) + if !ok { + respFail(w, r, http.StatusBadRequest, ErrorCode_BAD_REQUEST.MessageObjf("tag may only contain letters, numbers and Chinese characters")) + return + } + } + + acct.ClanTag = tag + if err := h.AccountStorage.SaveAccount(acct); err != nil { + respFail(w, r, http.StatusInternalServerError, ErrorCode_INTERNAL_SERVER_ERROR.MessageObj()) + return + } + respJSON(w, r, http.StatusOK, map[string]any{"success": true}) +} + +func (h *Handler) handleAccountsGetClanTag(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodOptions && r.Method != http.MethodHead && r.Method != http.MethodGet { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + if r.Method == http.MethodOptions { + w.Header().Set("Allow", "OPTIONS, HEAD, GET") + w.WriteHeader(http.StatusNoContent) + return + } + + uidQ := r.URL.Query().Get("uid") + if uidQ == "" { + respFail(w, r, http.StatusBadRequest, ErrorCode_BAD_REQUEST.MessageObjf("uid param is required")) + return + } + uid, err := strconv.ParseUint(uidQ, 10, 64) + if err != nil { + respFail(w, r, http.StatusNotFound, ErrorCode_PLAYER_NOT_FOUND.MessageObj()) + return + } + acct, err := h.AccountStorage.GetAccount(uid) + if err != nil { + respFail(w, r, http.StatusInternalServerError, ErrorCode_INTERNAL_SERVER_ERROR.MessageObj()) + return + } + var tag string + if acct != nil { + tag = acct.ClanTag + } + respJSON(w, r, http.StatusOK, map[string]any{"success": true, "uid": strconv.FormatUint(uid, 10), "clanTag": tag}) +} diff --git a/pkg/api/api0/api.go b/pkg/api/api0/api.go index 642ed06..bfa12e8 100644 --- a/pkg/api/api0/api.go +++ b/pkg/api/api0/api.go @@ -152,6 +152,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.handleAccountsWritePersistence(w, r) case "/accounts/get_username": h.handleAccountsGetUsername(w, r) + case "/accounts/set_clan_tag": + h.handleAccountsSetClanTag(w, r) + case "/accounts/get_clan_tag": + h.handleAccountsGetClanTag(w, r) case "/accounts/lookup_uid": h.handleAccountsLookupUID(w, r) case "/player/pdata", "/player/info", "/player/stats", "/player/loadout": diff --git a/pkg/api/api0/api0gameserver/nsserver.go b/pkg/api/api0/api0gameserver/nsserver.go index 38fa0ba..3cf98f1 100644 --- a/pkg/api/api0/api0gameserver/nsserver.go +++ b/pkg/api/api0/api0gameserver/nsserver.go @@ -68,12 +68,13 @@ func Verify(ctx context.Context, auth netip.AddrPort) error { // registers a one-time connection token, and sends the player's pdata. If the // authentication request returns invalid JSON, err is ErrInvalidResponse. If // the authentication response .success is false, err is ErrAuthFailed. -func AuthenticateIncomingPlayer(ctx context.Context, auth netip.AddrPort, uid uint64, username, connToken, serverToken string, pdata []byte) error { +func AuthenticateIncomingPlayer(ctx context.Context, auth netip.AddrPort, uid uint64, username, connToken, serverToken, clanTag string, pdata []byte) error { u := "http://" + auth.String() + "/authenticate_incoming_player" + "?id=" + strconv.FormatUint(uid, 10) + "&authToken=" + url.QueryEscape(connToken) + "&serverAuthToken=" + url.QueryEscape(serverToken) + - "&username=" + url.QueryEscape(username) + "&username=" + url.QueryEscape(username) + + "&clanTag=" + url.QueryEscape(clanTag) req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(pdata)) if err != nil { diff --git a/pkg/api/api0/client.go b/pkg/api/api0/client.go index c2f815e..add22a0 100644 --- a/pkg/api/api0/client.go +++ b/pkg/api/api0/client.go @@ -445,7 +445,7 @@ func (h *Handler) handleClientAuthWithServer(w http.ResponseWriter, r *http.Requ defer cancel() if srv.AuthPort != 0 { - if err := api0gameserver.AuthenticateIncomingPlayer(ctx, srv.AuthAddr(), acct.UID, acct.Username, authToken, srv.ServerAuthToken, pbuf); err != nil { + if err := api0gameserver.AuthenticateIncomingPlayer(ctx, srv.AuthAddr(), acct.UID, acct.Username, authToken, srv.ServerAuthToken, acct.ClanTag, pbuf); err != nil { h.m().client_authwithserver_gameserverauth_duration_seconds.UpdateDuration(authStart) if errors.Is(err, context.DeadlineExceeded) { err = fmt.Errorf("request timed out") @@ -485,6 +485,7 @@ func (h *Handler) handleClientAuthWithServer(w http.ResponseWriter, r *http.Requ "token": authToken, "uid": acct.UID, "username": acct.Username, + "clanTag": acct.ClanTag, "ip": raddr.Addr().String(), "time": time.Now().Unix(), } @@ -669,6 +670,7 @@ func (h *Handler) handleClientAuthWithSelf(w http.ResponseWriter, r *http.Reques obj := map[string]any{ "success": true, "id": strconv.FormatUint(acct.UID, 10), + "clanTag": acct.ClanTag, } // the way we encode this is utterly absurd and inefficient, but we need to do it for backwards compatibility diff --git a/pkg/api/api0/storage.go b/pkg/api/api0/storage.go index a6de4cd..73a5808 100644 --- a/pkg/api/api0/storage.go +++ b/pkg/api/api0/storage.go @@ -15,6 +15,9 @@ type Account struct { // optional and case insensitive. Username string + // ClanTag is the player's chosen clan tag. + ClanTag string + // AuthIP is the IP used for the current auth session. AuthIP netip.Addr