feat: expose account registration, login and admin recovery over HTTP

This commit is contained in:
DiheChen committed 2026-11-09 21:21:23 +08:00
1 parent cc46cc5684
commit a5749aeb7b
16 files changed
+1455 -53

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
package api
import (
"context"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/protocol"
)
func (s *server) register(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRegisterRequest(c.body)
if err != nil {
return nil, err
}
account, err := s.Accounts.Register(ctx, c.member.ID, c.clientIP, req.Username, req.Password)
if err != nil {
return nil, err
}
return protocol.RegisterResponse{Account: accountDTO(account)}, nil
}
func (s *server) login(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeLoginRequest(c.body)
if err != nil {
return nil, err
}
session, account, err := s.Accounts.Login(ctx, c.member, bearer(c.r), c.clientIP, req.Username, req.Password)
if err != nil {
return nil, err
}
return protocol.LoginResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account)}, nil
}
func (s *server) logout(ctx context.Context, c call) (any, error) {
return nil, s.Accounts.Logout(ctx, bearer(c.r))
}
func (s *server) changePassword(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeChangePasswordRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Accounts.ChangePassword(ctx, c.member.ID, bearer(c.r), req.CurrentPassword, req.NewPassword)
}
func (s *server) adminGetAccount(ctx context.Context, c call) (any, error) {
username := c.r.PathValue("username")
if !usernamePattern.MatchString(username) {
return nil, identity.ErrAccountNotFound
}
account, err := s.Accounts.Find(ctx, username)
if err != nil {
return nil, err
}
return protocol.AdminAccount{Member: memberDTO(account.Member), Username: account.Username}, nil
}
func (s *server) adminResetAccountPassword(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeAdminResetAccountPasswordRequest(c.body); err != nil {
return nil, err
}
username := c.r.PathValue("username")
if !usernamePattern.MatchString(username) {
return nil, identity.ErrAccountNotFound
}
password, err := s.Accounts.ResetPassword(ctx, username)
if err != nil {
return nil, err
}
return protocol.AdminResetAccountPasswordResponse{Password: password}, nil
}
func accountDTO(a identity.Account) protocol.Account {
dto := protocol.Account{Username: a.Username}
if a.Email != "" {
dto.Email = &a.Email
}
return dto
}
+18
View File
@@ -37,6 +37,7 @@ const (
type Services struct {
Identity *identity.Service
Accounts *identity.Accounts
Lobby *lobby.Service
Admin *admin.Service
Gateway *realtime.Gateway
@@ -98,6 +99,10 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteCreateMember, srv.createMember)
srv.handle(mux, protocol.RouteGetMe, srv.getMe)
srv.handle(mux, protocol.RouteUpdateMe, srv.updateMe)
srv.handle(mux, protocol.RouteRegister, srv.register)
srv.handle(mux, protocol.RouteChangePassword, srv.changePassword)
srv.handle(mux, protocol.RouteLogin, srv.login)
srv.handle(mux, protocol.RouteLogout, srv.logout)
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
@@ -127,6 +132,10 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteAdminListMedia, srv.adminListMedia)
srv.handle(mux, protocol.RouteAdminDeleteMedia, srv.adminDeleteMedia)
srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite)
srv.handle(mux, protocol.RouteAdminOpenRegistration, srv.adminOpenRegistration)
srv.handle(mux, protocol.RouteAdminCloseRegistration, srv.adminCloseRegistration)
srv.handle(mux, protocol.RouteAdminGetAccount, srv.adminGetAccount)
srv.handle(mux, protocol.RouteAdminResetAccountPassword, srv.adminResetAccountPassword)
mux.HandleFunc("GET /ws", srv.websocket)
mux.HandleFunc("GET /api/room/events", srv.roomEvents)
mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia)
@@ -279,6 +288,15 @@ var knownErrors = []struct {
{identity.ErrInvalidTicket, protocol.ErrorCodeUnauthorized},
{identity.ErrBotSession, protocol.ErrorCodeUnauthorized},
{identity.ErrInvalidEmail, protocol.ErrorCodeInvalidArgument},
{identity.ErrInvalidCredentials, protocol.ErrorCodeInvalidCredentials},
{identity.ErrWrongCurrentPassword, protocol.ErrorCodeInvalidCredentials},
{identity.ErrAlreadyLoggedIn, protocol.ErrorCodeConflict},
{identity.ErrRegistrationClosed, protocol.ErrorCodeForbidden},
{identity.ErrNotRegistered, protocol.ErrorCodeForbidden},
{identity.ErrTooManyAttempts, protocol.ErrorCodeRateLimited},
{identity.ErrAccountNotFound, protocol.ErrorCodeNotFound},
{port.ErrUsernameTaken, protocol.ErrorCodeConflict},
{port.ErrAlreadyRegistered, protocol.ErrorCodeConflict},
{realtime.ErrRequesterNotAllowed, protocol.ErrorCodeInvalidArgument},
{site.ErrInvalidBrandName, protocol.ErrorCodeInvalidArgument},
{realtime.ErrUnavailable, protocol.ErrorCodeUnavailable},
+1
View File
@@ -42,6 +42,7 @@ func services(store *porttest.Store, rooms port.RoomDirectory, adminToken string
l := lobby.New(rooms, node)
return Services{
Identity: identity.New(store, rooms, store, gateway),
Accounts: identity.NewAccounts(store, store, nil),
Lobby: l,
Admin: admin.New(adminToken, l, rooms, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), gateway),
Gateway: gateway,
+15 -5
View File
@@ -13,11 +13,12 @@ import (
"github.com/dihemoe/alisten/protocol"
)
// 路径中的房间 ID 和别名不经过协议解码。格式不正确的房间 ID 或别名一定不存在,其中含有 U+0000 等字符的还会让
// 路径中的房间 ID、别名和用户名不经过协议解码。格式不正确的房间 ID、别名或用户名一定不存在,其中含有 U+0000 等字符的还会让
// PostgreSQL 报错,所以直接按不存在处理。
var (
uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
slugPattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
slugPattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
usernamePattern = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
)
func (s *server) createMember(ctx context.Context, c call) (any, error) {
@@ -31,8 +32,17 @@ func (s *server) createMember(ctx context.Context, c call) (any, error) {
return protocol.CreateMemberResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member)}, nil
}
func (s *server) getMe(_ context.Context, c call) (any, error) {
return protocol.GetMeResponse{Member: memberDTO(c.member)}, nil
func (s *server) getMe(ctx context.Context, c call) (any, error) {
resp := protocol.GetMeResponse{Member: memberDTO(c.member)}
account, registered, err := s.Accounts.Account(ctx, c.member.ID)
if err != nil {
return nil, err
}
if registered {
dto := accountDTO(account)
resp.Account = &dto
}
return resp, nil
}
func (s *server) updateMe(ctx context.Context, c call) (any, error) {
+12 -1
View File
@@ -11,7 +11,7 @@ func (s *server) getSite(ctx context.Context, _ call) (any, error) {
if err != nil {
return nil, err
}
return protocol.GetSiteResponse{BrandName: settings.BrandName}, nil
return protocol.GetSiteResponse{BrandName: settings.BrandName, RegistrationOpen: settings.RegistrationOpen}, nil
}
func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
@@ -25,3 +25,14 @@ func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
}
return protocol.AdminUpdateSiteResponse{BrandName: name}, nil
}
func (s *server) adminOpenRegistration(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeAdminOpenRegistrationRequest(c.body); err != nil {
return nil, err
}
return nil, s.Site.SetRegistrationOpen(ctx, true)
}
func (s *server) adminCloseRegistration(ctx context.Context, _ call) (any, error) {
return nil, s.Site.SetRegistrationOpen(ctx, false)
}
+6 -3
View File
@@ -15,14 +15,17 @@ func TestSite(t *testing.T) {
brandName := func(name string) response {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `"}`}
}
site := func(name string) response {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `","registrationOpen":true}`}
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("Alisten") {
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("Alisten") {
t.Errorf("没有设置过时 GET /api/site = %#v,期望默认的 Alisten", got)
}
if got := send(t, server, http.MethodPatch, "/api/admin/site", `{"brandName":" 周末听歌会 "}`, adminHeader...); got != brandName("周末听歌会") {
t.Errorf("PATCH /api/admin/site = %#v,期望返回去掉首尾空白之后存下的名字", got)
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("周末听歌会") {
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("周末听歌会") {
t.Errorf("修改之后 GET /api/site = %#v,期望去掉首尾空白的新名字", got)
}
@@ -36,7 +39,7 @@ func TestSite(t *testing.T) {
if got := send(t, server, http.MethodPatch, "/api/admin/site", `{"brandName":"别的名字"}`); got != unauthorized {
t.Errorf("不带管理 token 的 PATCH = %#v,期望 %#v", got, unauthorized)
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("周末听歌会") {
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("周末听歌会") {
t.Errorf("被拒绝的修改之后 GET /api/site = %#v,期望不变", got)
}
if logs.Len() != 0 {
+1
View File
@@ -78,6 +78,7 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
rooms := lobby.New(store, leases)
services := api.Services{
Identity: identity.New(store, store, store, gateway),
Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb)),
Lobby: rooms,
Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway),
Gateway: gateway,
+129
View File
@@ -0,0 +1,129 @@
登录按客户端 IP 限流:同一个 IP 1 分钟内最多尝试 10 次,换一个用户名也算在内;请求经受信任的代理转发时,按 X-Forwarded-For 中的客户端地址计数,另一个客户端不受影响。
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$guest", "member": {"id": "$guestId", "name": "$guestName", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user01", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user02", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user03", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user04", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user05", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user06", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user07", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user08", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user09", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user10", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user11", "password": "wrong horse"}
-- response --
429
{"error": {"code": "RATE_LIMITED", "message": "尝试次数太多,请稍后再试。"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.8
{"username": "user12", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
+258
View File
@@ -0,0 +1,258 @@
账号:游客注册之后成员和成员 token 都不变;其他游客用账号登录,得到新的成员 token,自己原来的 token 随即失效;修改密码、退出登录让对应的成员 token 失效;后台可以查找账号、重置密码。
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $alice
{"username": "alice", "password": "correct horse"}
-- response --
201
{"account": {"username": "alice"}}
-- request --
GET /api/me
Authorization: Bearer $alice
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
-- request --
PATCH /api/me
Authorization: Bearer $alice
{"email": " Alice@Example.COM "}
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
-- request --
GET /api/me
Authorization: Bearer $alice
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}}
-- request --
POST /api/account
Authorization: Bearer $alice
{"username": "alice2", "password": "correct horse"}
-- response --
409
{"error": {"code": "CONFLICT", "message": "已经注册过了"}}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
GET /api/me
Authorization: Bearer $bob
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
PATCH /api/me
Authorization: Bearer $bob
{"email": "Alice@Example.COM"}
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
-- request --
GET /api/me
Authorization: Bearer $bob
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
-- request --
PATCH /api/me
Authorization: Bearer $bob
{"email": ""}
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "alice", "password": "another horse"}
-- response --
409
{"error": {"code": "CONFLICT", "message": "用户名已被占用"}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "Bob", "password": "another horse"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "username:格式不正确"}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "bob", "password": "short"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "password:长度不能少于 8 个字符"}}
-- request --
POST /api/sessions
Authorization: Bearer $bob
{"username": "alice", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $bob
{"username": "alice", "password": "correct horse"}
-- response --
201
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}}
-- request --
GET /api/me
Authorization: Bearer $alice2
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}}
-- request --
PATCH /api/me
Authorization: Bearer $alice2
{"email": ""}
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
GET /api/me
Authorization: Bearer $alice
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
-- request --
GET /api/me
Authorization: Bearer $bob
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
POST /api/sessions
Authorization: Bearer $alice2
{"username": "alice", "password": "correct horse"}
-- response --
409
{"error": {"code": "CONFLICT", "message": "已经登录了账号,先退出再登录其他账号"}}
-- request --
PUT /api/account/password
Authorization: Bearer $alice
{"currentPassword": "wrong horse", "newPassword": "battery staple"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "当前密码错误"}}
-- request --
PUT /api/account/password
Authorization: Bearer $alice
{"currentPassword": "correct horse", "newPassword": "battery staple"}
-- response --
204
-- request --
GET /api/me
Authorization: Bearer $alice2
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
DELETE /api/sessions/current
Authorization: Bearer $alice
-- response --
204
-- request --
GET /api/me
Authorization: Bearer $alice
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
GET /api/admin/accounts/alice
Authorization: Bearer admin-secret
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "username": "alice"}
-- request --
GET /api/admin/accounts/nobody
Authorization: Bearer admin-secret
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "没有这个用户名的账号"}}
-- request --
GET /api/admin/accounts/Not%20Valid
Authorization: Bearer admin-secret
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "没有这个用户名的账号"}}
-- request --
POST /api/admin/accounts/alice/password
Authorization: Bearer admin-secret
{}
-- response --
200
{"password": "$resetPassword"}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$dave", "member": {"id": "$daveId", "name": "$daveName", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $dave
{"username": "alice", "password": "$resetPassword"}
-- response --
201
{"memberToken": "$alice4", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
+50 -4
View File
@@ -1,10 +1,10 @@
站点:品牌名默认是 Alisten;后台修改时返回存下的名字,首尾的空白已经去掉,之后读到的也是它;空的、超过 20 个字符的和看不见的名字不能保存;修改品牌名要管理 token。
站点:品牌名默认是 Alisten,默认开放注册;后台修改品牌名时返回存下的名字,首尾的空白已经去掉,之后读到的也是它;空的、超过 20 个字符的和看不见的名字不能保存;后台可以关闭和重新开放注册,关闭之后游客不能注册;修改站点设置要管理 token。
-- request --
GET /api/site
-- response --
200
{"brandName": "Alisten"}
{"brandName": "Alisten", "registrationOpen": true}
-- request --
PATCH /api/admin/site
@@ -19,7 +19,7 @@ Authorization: Bearer admin-secret
GET /api/site
-- response --
200
{"brandName": "周末听歌会"}
{"brandName": "周末听歌会", "registrationOpen": true}
-- request --
PATCH /api/admin/site
@@ -56,8 +56,54 @@ PATCH /api/admin/site
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
DELETE /api/admin/registration
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
DELETE /api/admin/registration
Authorization: Bearer admin-secret
-- response --
204
-- request --
GET /api/site
-- response --
200
{"brandName": "周末听歌会"}
{"brandName": "周末听歌会", "registrationOpen": false}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$guest", "member": {"id": "$guestId", "name": "$guestName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $guest
{"username": "guest", "password": "correct horse"}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "注册已关闭"}}
-- request --
PUT /api/admin/registration
Authorization: Bearer admin-secret
{}
-- response --
204
-- request --
POST /api/account
Authorization: Bearer $guest
{"username": "guest", "password": "correct horse"}
-- response --
201
{"account": {"username": "guest"}}