Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion db/atlasdb/001_init_db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions db/atlasdb/002_add_clan_tag.go
Original file line number Diff line number Diff line change
@@ -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 '' COLLATE NOCASE`); err != nil {
Comment thread
86Yin marked this conversation as resolved.
Outdated
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
}
7 changes: 5 additions & 2 deletions db/atlasdb/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
106 changes: 106 additions & 0 deletions pkg/api/api0/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Comment thread
pg9182 marked this conversation as resolved.
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})
}
4 changes: 4 additions & 0 deletions pkg/api/api0/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
5 changes: 3 additions & 2 deletions pkg/api/api0/api0gameserver/nsserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion pkg/api/api0/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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(),
}
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions pkg/api/api0/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down