Skip to content

03 · 登录与会话 ​

POST /session 是整个协议里字段最多、最容易翻车的一个接口:它同时承担登录、建号、下发全部初始数据三件事。

请求 ​

客户端发的字段比服务端需要的多得多。完整请求体(来自参考客户端的 DTO):

jsonc
{
  "provider": "device",
  "provider_details": { "payment_provider": "notavailable" },
  "client_type": "desktop",
  "build": "…",
  "platform_type": "Windows",
  "app_guid": "…",
  "version": "Kards 1.54",
  "platform_info": "…",
  "platform_version": "…",
  "account_linking": "",
  "language": "zh-Hans",
  "automatic_account_creation": true,
  "username": "vekolyram",
  "password": ""
}

服务端其实只关心两个字段

Go 实现只绑定 username / password 两个字段,其余全部忽略。但不要只回字段名不认就报错——不少框架的严格校验器会因为未知字段返回 400。要么关掉 forbidNonWhitelisted,要么把 DTO 写全。

响应:SessionResponse ​

字段顺序与官方示例一致,按字母序排列:

分组字段
身份client_id user_id player_id player_name player_tag jti jwt locale currency
货币/进度gold dust diamonds stars season_id season_wins season_end
五国等级germany_level germany_level_claimed germany_xp(同构:britain_ / japan_ / soviet_ / usa_)
卡组decks({ "headers": [...] })
子资源 URLachievements_url dailymissions_url decks_url heartbeat_url library_url packs_url
服务端配置server_options(字符串,内容是 JSON) server_time versions
杂项misc rewards new_player_login_reward cards_blacklist launch_messages tutorials_done tutorials_finished

一个能过客户端的最小响应:

jsonc
{
  "client_id": 1, "user_id": 1, "player_id": 1,
  "player_name": "<anon>", "player_tag": 1234,
  "jti": "1", "jwt": "<token>", "locale": "zh-Hans", "currency": "USD",
  "gold": 999999, "dust": 1000, "diamonds": 99999, "stars": 120,
  "britain_level": 500, "britain_level_claimed": 500, "britain_xp": 0,
  "germany_level": 500, "germany_level_claimed": 500, "germany_xp": 0,
  "japan_level":   500, "japan_level_claimed":   500, "japan_xp":   0,
  "soviet_level":  500, "soviet_level_claimed":  500, "soviet_xp":  0,
  "usa_level":     500, "usa_level_claimed":     500, "usa_xp":     0,
  "decks": { "headers": [] },
  "decks_url": "http://127.0.0.1:5231/players/1/decks",
  "library_url": "http://127.0.0.1:5231/players/1/library",
  "heartbeat_url": "http://127.0.0.1:5231/players/1/heartbeat",
  "server_options": "{\"nui_mobile\":1,\"battle_wait_time\":60,\"websocketurl\":\"ws://127.0.0.1:5232/ws\"}",
  "server_time": "2025.07.06-04.06.03",
  "tutorials_done": 0,
  "tutorials_finished": [],
  "is_officer": true, "has_been_officer": true, "is_online": true, "online_flag": true,
  "email": null, "rewards": { "packs": 0, "gold_max": 0, "gold_min": 0 },
  "new_cards": [], "cards_blacklist": [], "launch_messages": [],
  "misc": { "createDate": "2025-07-02T11:24:15.529671Z", "featuredAchievements": [] }
}

server_options 是字符串

它的值是一段序列化后的 JSON 文本,不是 JSON 对象。websocketurl 就在里面——这是服务端告诉客户端"实时通道去哪连"的唯一途径。三个实现都这么干:

go
// Go:结构体 → string → 塞进响应
serverOptionsBytes, _ := json.Marshal(opts)
// opts.WebsocketURL = fmt.Sprintf("ws://%s:%d/ws", config.Host, config.WSPort)
csharp
// C#:直接写模板字符串
ServerOptions = $"{{\"nui_mobile\":1,\"battle_wait_time\":60,\"websocketurl\":\"ws://{ip}:{port}\"}}"

签发 token ​

身份不是"用户名",而是服务端签发的一串 token。客户端把它存下来,之后所有请求都带:

http
Authorization: JWT <token>

参考实现有两种截然不同的设计:

方案实现token 内容校验方式
真 JWTkardsservergoHS256,claims = {user_id, username, exp}验签 + 与库里的 PlayerJWT 全等比对
codec tokenfyservercodec.Encode(username, 114)解出用户名 → 查库

方案 A:HS256 JWT(推荐) ​

csharp
// Services/AuthService.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;

public class AuthService
{
    private readonly byte[] _key = Encoding.UTF8.GetBytes("换成你自己的长随机串");
    private static readonly TimeSpan Ttl = TimeSpan.FromHours(24);

    public string Issue(int userId, string userName)
    {
        var creds = new SigningCredentials(new SymmetricSecurityKey(_key), SecurityAlgorithms.HmacSha256);
        var token = new JwtSecurityToken(
            claims: new[]
            {
                new Claim("user_id", userId.ToString()),
                new Claim("username", userName),
            },
            expires: DateTime.UtcNow.Add(Ttl),
            signingCredentials: creds);
        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    /// <summary>校验签名与有效期,返回 userId;失败返回 null。</summary>
    public int? Validate(string token)
    {
        try
        {
            var handler = new JwtSecurityTokenHandler();
            var principal = handler.ValidateToken(token, new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(_key),
                ValidateIssuer = false,
                ValidateAudience = false,
                ValidateLifetime = true,
                ClockSkew = TimeSpan.FromMinutes(1),
            }, out _);
            var raw = principal.FindFirst("user_id")?.Value;
            return int.TryParse(raw, out var id) && id > 0 ? id : null;
        }
        catch { return null; }
    }
}
typescript
// src/session/auth.service.ts
import { Injectable } from '@nestjs/common';
import * as jwt from 'jsonwebtoken';

const SECRET = process.env.JWT_SECRET ?? '换成你自己的长随机串';
const TTL = '24h';

@Injectable()
export class AuthService {
  issue(userId: number, userName: string): string {
    return jwt.sign({ user_id: userId, username: userName }, SECRET, { expiresIn: TTL, algorithm: 'HS256' });
  }

  /** 校验签名与有效期,返回 userId;失败返回 null */
  validate(token: string): number | null {
    try {
      const payload = jwt.verify(token, SECRET, { algorithms: ['HS256'] }) as { user_id?: number };
      return payload.user_id && payload.user_id > 0 ? payload.user_id : null;
    } catch {
      return null;
    }
  }
}

为什么还要和数据库里的 token 全等比对

Go 实现在验签之后多做了一步:user.PlayerJWT != tokenStr → 401。这样每次登录都会让旧 token 立即失效(单设备登录)。如果你希望多端同时在线,去掉这一步即可;保留则更接近官方行为。

鉴权中间件 ​

所有非白名单接口都要过这一关。白名单只有三个:/、/session、/.com/config。

csharp
// Middleware/JwtAuthMiddleware.cs
public class JwtAuthMiddleware(RequestDelegate next, AuthService auth, UserStoreService users)
{
    private static readonly HashSet<string> Public = new(StringComparer.OrdinalIgnoreCase)
        { "/", "/session", "/.com/config" };

    public async Task InvokeAsync(HttpContext ctx)
    {
        if (Public.Contains(ctx.Request.Path.Value ?? "")) { await next(ctx); return; }

        var header = ctx.Request.Headers.Authorization.ToString();
        var token = header.StartsWith("JWT ", StringComparison.Ordinal) ? header[4..]
                  : header.StartsWith("Bearer ", StringComparison.Ordinal) ? header[7..]
                  : header;

        var userId = token.Length == 0 ? null : auth.Validate(token);
        var user = userId is null ? null : await users.GetByIdAsync(userId.Value);
        if (user is null || user.PlayerJwt != token)
        {
            // 注意:401 的响应体也要是 JSON
            ctx.Response.StatusCode = 401;
            ctx.Response.ContentType = "application/json";
            await ctx.Response.WriteAsync("""{"title":"401 Unauthorized","description":"Warning"}""");
            return;
        }

        ctx.Items["user"] = user;
        await next(ctx);
    }
}
typescript
// src/session/auth.middleware.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { users } from '../user';

const PUBLIC = new Set(['/', '/session', '/.com/config']);

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private readonly auth: AuthService) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest();
    if (PUBLIC.has(req.path)) return true;

    const header: string = req.headers['authorization'] ?? '';
    const token = header.startsWith('JWT ') ? header.slice(4)
                : header.startsWith('Bearer ') ? header.slice(7)
                : header;
    if (!token) throw new UnauthorizedException({ title: '401 Unauthorized', description: 'Warning' });

    const userId = this.auth.validate(token);
    if (!userId) throw new UnauthorizedException({ title: '401 Unauthorized', description: 'Warning' });

    const raw = await users.get(String(userId)).catch(() => null);
    if (!raw) throw new UnauthorizedException({ title: '401 Unauthorized', description: 'Warning' });
    const user = JSON.parse(raw);
    if (user.player_jwt !== token) throw new UnauthorizedException({ title: '401 Unauthorized', description: 'Warning' });

    req.user = user;
    return true;
  }
}

封禁 ​

封禁不只是登录时拒绝,还要立刻踢掉在线连接。封禁响应用 403:

jsonc
// fyserver 的 BannedResponse
{
  "error": { "title": "user_error", "description": "banned" },
  "…": "Forbidden",
  "status": 403
}
csharp
if (user.Banned)
{
    await webSockets.DisconnectAsync(user.Id, "该账户已被封禁"); // 给 WS 发 channel: disconnect
    return Results.Json(new { error = new { title = "user_error", description = "banned" } }, statusCode: 403);
}
typescript
if (user.banned) {
  clients[user.id]?.client.send(JSON.stringify({ channel: 'disconnect', message: '该账户已被封禁' }));
  throw new ForbiddenException({ error: { title: 'user_error', description: 'banned' } });
}

token 有了,下一章看它怎么被用来加密对局消息——这是本协议最独特的设计。