Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 ''`); 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
}
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