feat(backend): 앱 전용 토큰 로그인 · 갱신 · 로그아웃 경로를 만든다

계약 1절 "앱 로그인"이다(웹의 쿠키를 앱에 쓰지 않는다). 세션 규칙은 웹과 같고 토큰을 나르는
길만 다르다.

- POST /api/auth/app/login · refresh · logout — 같은 AuthService.login 을 지나 실패 카운터 ·
  2단계 코드 가드 · 앱 비밀번호 모양 거절 · 관리자 차단이 웹과 같고, 로그인 요청 수 한도는
  웹 · 앱이 한 칸을 함께 쓴다. 토큰 응답은 private, no-store
- Bearer 는 앱 표시(cli: app)와 UUID sid 가 있는 액세스 토큰만, 쿠키는 웹 토큰만 — 리프레시 토큰 ·
  세션 표의 client 도 맞아야 하고 다르면 401(재사용으로 보지 않음)
- 앱 세션의 갱신 유예는 그 세션을 끝낸다(app-refresh-grace — 응답을 잃었거나 토큰을 훔친 것이다)
- 지금 세션은 앱이면 액세스 토큰의 sid(비밀번호 바꾸기 · 로그인된 기기 · 앱 logout), 로그아웃은
  리프레시 토큰이 틀리면 sid 로 끝낸다
- 세션 표 client 칸(마이그레이션 AddAppSessionClient — 기본 web), 세션 목록 항목에 client,
  앱 이름 "ARA SYSTEM 앱"은 client · method 로만(꾸민 User-Agent 로 앱처럼 보이지 않게),
  로그인 기록 method app
- 운영 도구: 현황의 마지막 로그인이 앱도 세고, 세션 끊기 글 · 직원 알림 메일이 앱을 말한다
- Swagger 에 Bearer 를 등록하고 쿠키로 보호하는 작업마다 "또는"으로 붙인다

리뷰 반영: 유예에서 앱 세션 끊기, 앱 이름을 진짜 클라이언트로만, sid 없는 앱 토큰 거절,
빈 · 너무 긴 리프레시 토큰은 401(로그아웃은 sid 로), 시험(웹 쿠키 + 앱 logout 등).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 16:38:17 +09:00
parent 2bb7fa4cdb
commit 6ee6c71ace
48 changed files with 2783 additions and 242 deletions
+579
View File
@@ -27,6 +27,7 @@ import {
ACCOUNT_SECURITY_SETTINGS,
type AccountSecuritySettings,
} from '@/modules/account-security/account-security.settings';
import { MAX_REFRESH_TOKEN_LENGTH } from '@/modules/auth/auth.constants';
import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
import { MAX_CONTACTS } from '@/modules/contacts/contacts.constants';
import { DirectoryService } from '@/modules/contacts/directory.service';
@@ -7887,6 +7888,584 @@ describe('응답 계약 (e2e)', () => {
});
});
describe('앱 1단계 — 앱 전용 토큰 경로 (/api/auth/app/* · Bearer)', () => {
const APP_UA = 'ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)';
interface AppTokens {
accessToken: string;
accessExpiresAt: string;
refreshToken: string;
refreshExpiresAt: string;
}
interface AppSession {
status?: string;
email: string | null;
name: string | null;
restriction: string | null;
twoFactorDeadline: string | null;
tokens: AppTokens | null;
}
const appLogin = (email: string, from: string, extra: object = {}) =>
request(server)
.post('/api/auth/app/login')
.set('X-Forwarded-For', from)
.set('User-Agent', APP_UA)
.send({ email, password: CORRECT_PASSWORD, ...extra });
const appRefresh = (refreshToken: string) =>
request(server)
.post('/api/auth/app/refresh')
.set('X-Forwarded-For', '198.18.20.250')
.set('User-Agent', APP_UA)
.send({ refreshToken });
const webLogin = (email: string, from: string) =>
request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', from)
.send({ email, password: CORRECT_PASSWORD });
/** 응답의 tokens — 없으면 시험이 틀렸다 */
const tokensOf = (res: Response): AppTokens => {
const tokens = dataOf<AppSession>(res).tokens;
if (!tokens) throw new Error('tokens 가 있어야 한다');
return tokens;
};
const bearer = (token: string) => `Bearer ${token}`;
const sidOf = (token: string) => jwt.decode<{ sid: string }>(token).sid;
const meWith = (token: string) =>
request(server).get('/api/auth/me').set('Authorization', bearer(token));
it('앱 로그인 → 웹 login 과 같은 칸 + tokens(no-store · 쿠키 없음, 두 토큰에 cli: app) → Bearer 로 me · 보호 API', async () => {
const ME = 'app.login@e2e.arasystem.test';
const res = await appLogin(ME, '198.18.20.1').expect(200);
expect(res.headers['cache-control']).toBe('private, no-store');
expect(res.headers['set-cookie']).toBeUndefined();
expect(dataOf<AppSession>(res)).toEqual({
status: 'signedIn',
email: ME,
name: EMPLOYEE.name,
...UNRESTRICTED,
tokens: {
accessToken: expect.any(String) as unknown,
accessExpiresAt: expect.any(String) as unknown,
refreshToken: expect.any(String) as unknown,
refreshExpiresAt: expect.any(String) as unknown,
},
});
const tokens = tokensOf(res);
const access = jwt.decode<{ typ: string; cli?: string; exp: number }>(
tokens.accessToken,
);
const refresh = jwt.decode<{ typ: string; cli?: string; exp: number }>(
tokens.refreshToken,
);
expect(access).toMatchObject({ typ: 'access', cli: 'app' });
expect(refresh).toMatchObject({ typ: 'refresh', cli: 'app' });
expect(new Date(tokens.accessExpiresAt).getTime()).toBe(
access.exp * 1000,
);
expect(new Date(tokens.refreshExpiresAt).getTime()).toBe(
refresh.exp * 1000,
);
const me = await meWith(tokens.accessToken).expect(200);
expect(body(me).data).toEqual({
...EMPLOYEE,
email: ME,
...UNRESTRICTED,
});
await request(server)
.get('/api/mailboxes')
.set('Authorization', bearer(tokens.accessToken))
.expect(200);
// Bearer 없이는 로그인 전과 같다
await request(server).get('/api/mailboxes').expect(401);
});
it('웹 토큰을 Bearer 로 · 앱 토큰을 쿠키로 보내면 401 AUTH_001 — 리프레시 토큰도 서로의 갱신에서 401 (세션은 끊지 않는다)', async () => {
const ME = 'app.cross@e2e.arasystem.test';
const web = await webLogin(ME, '198.18.20.2').expect(200);
const webAccess = cookieValue(setCookie(web, 'access_token'));
const webRefresh = cookieValue(setCookie(web, 'refresh_token'));
const appTokens = tokensOf(await appLogin(ME, '198.18.20.3').expect(200));
const webAsBearer = await meWith(webAccess).expect(401);
expect(body(webAsBearer).error?.code).toBe('AUTH_001');
const appAsCookie = await request(server)
.get('/api/auth/me')
.set('Cookie', `access_token=${appTokens.accessToken}`)
.expect(401);
expect(body(appAsCookie).error?.code).toBe('AUTH_001');
// Bearer 가 있으면 같은 요청의 쿠키는 보지 않는다
await request(server)
.get('/api/auth/me')
.set('Cookie', `access_token=${webAccess}`)
.set('Authorization', bearer(webAccess))
.expect(401);
const appAtWeb = await refreshWith(appTokens.refreshToken).expect(401);
expect(body(appAtWeb).error?.code).toBe('AUTH_001');
const webAtApp = await appRefresh(webRefresh).expect(401);
expect(body(webAtApp).error?.code).toBe('AUTH_001');
// 제 길로는 그대로 된다 — 위의 거절이 세션을 끊지 않았다
await appRefresh(appTokens.refreshToken).expect(200);
await refreshWith(webRefresh).expect(200);
});
it('앱 갱신 — 1회용 회전(no-store) · 이미 쓴 토큰(두 번 지난 것)은 세션 전체를 끊는다 · 형식 오류는 400 · 빈 글 · 긴 글 · 틀린 토큰은 401', async () => {
const ME = 'app.refresh@e2e.arasystem.test';
const first = tokensOf(await appLogin(ME, '198.18.20.4').expect(200));
const rotated = await appRefresh(first.refreshToken).expect(200);
expect(rotated.headers['cache-control']).toBe('private, no-store');
expect(rotated.headers['set-cookie']).toBeUndefined();
expect(dataOf<AppSession>(rotated)).toMatchObject({
email: ME,
name: EMPLOYEE.name,
...UNRESTRICTED,
});
const second = tokensOf(rotated);
expect(second.refreshToken).not.toBe(first.refreshToken);
await meWith(second.accessToken).expect(200);
const third = tokensOf(await appRefresh(second.refreshToken).expect(200));
const reused = await appRefresh(first.refreshToken).expect(401);
expect(body(reused).error?.code).toBe('AUTH_001');
// 세션이 끊겼다 — 최신 리프레시 토큰도, 그 액세스 토큰도 401
await appRefresh(third.refreshToken).expect(401);
await meWith(third.accessToken).expect(401);
for (const token of [
'garbage',
'',
'x'.repeat(MAX_REFRESH_TOKEN_LENGTH + 1),
]) {
const rejected = await appRefresh(token).expect(401);
expect(body(rejected).error?.code).toBe('AUTH_001');
// 오류 응답도 no-store (계약 1절 "앱 로그인")
expect(rejected.headers['cache-control']).toBe('private, no-store');
}
for (const payload of [{}, { refreshToken: 12345 }]) {
const malformed = await request(server)
.post('/api/auth/app/refresh')
.send(payload)
.expect(400);
expect(body(malformed).error?.code).toBe('VAL_001');
expect(malformed.headers['cache-control']).toBe('private, no-store');
}
});
it('앱 세션의 30초 유예는 tokens: null 로 답하고 그 세션을 끝낸다(새 토큰 · 액세스 토큰도 401) — 웹 세션의 유예는 세션을 남긴다', async () => {
const ME = 'app.grace@e2e.arasystem.test';
const first = tokensOf(await appLogin(ME, '198.18.20.19').expect(200));
const second = tokensOf(await appRefresh(first.refreshToken).expect(200));
const grace = await appRefresh(first.refreshToken).expect(200);
expect(grace.headers['cache-control']).toBe('private, no-store');
expect(dataOf<AppSession>(grace)).toEqual({
email: ME,
name: EMPLOYEE.name,
...UNRESTRICTED,
tokens: null,
});
await appRefresh(second.refreshToken).expect(401);
await meWith(second.accessToken).expect(401);
const rows: { revoked_reason: string | null }[] = await dataSource.query(
'SELECT revoked_reason FROM auth_sessions WHERE id = $1',
[sidOf(second.refreshToken)],
);
expect(rows).toEqual([{ revoked_reason: 'app-refresh-grace' }]);
// 웹 — 유예 뒤에도 이긴 쪽의 쿠키로 이어 쓴다
const web = await webLogin(ME, '198.18.20.20').expect(200);
const webFirst = cookieValue(setCookie(web, 'refresh_token'));
const webSecond = rotated(await refreshWith(webFirst).expect(200));
const webGrace = await refreshWith(webFirst).expect(200);
expect(webGrace.headers['set-cookie']).toBeUndefined();
await refreshWith(webSecond).expect(200);
});
it('앱 로그아웃 — 리프레시 토큰으로 · 틀리거나 없으면 Bearer 의 sid 로 · 둘 다 틀려도 200, 끝낸 세션의 액세스 토큰은 바로 401', async () => {
const ME = 'app.logout@e2e.arasystem.test';
const logout = () => request(server).post('/api/auth/app/logout');
const byRefresh = tokensOf(await appLogin(ME, '198.18.20.5').expect(200));
const first = await logout()
.send({ refreshToken: byRefresh.refreshToken })
.expect(200);
expect(body(first)).toEqual({ success: true, data: null });
await appRefresh(byRefresh.refreshToken).expect(401);
await meWith(byRefresh.accessToken).expect(401);
const wrongRefresh = tokensOf(
await appLogin(ME, '198.18.20.6').expect(200),
);
await logout()
.set('Authorization', bearer(wrongRefresh.accessToken))
.send({ refreshToken: 'garbage' })
.expect(200);
await appRefresh(wrongRefresh.refreshToken).expect(401);
const emptyBody = tokensOf(await appLogin(ME, '198.18.20.7').expect(200));
await logout()
.set('Authorization', bearer(emptyBody.accessToken))
.send({})
.expect(200);
await appRefresh(emptyBody.refreshToken).expect(401);
// 빈 글 · 긴 글도 틀린 토큰처럼 보고 Bearer 의 sid 로 끝낸다 (200)
for (const refreshToken of [
'',
'x'.repeat(MAX_REFRESH_TOKEN_LENGTH + 1),
]) {
const odd = tokensOf(await appLogin(ME, '198.18.20.21').expect(200));
await logout()
.set('Authorization', bearer(odd.accessToken))
.send({ refreshToken })
.expect(200);
await appRefresh(odd.refreshToken).expect(401);
}
// 본문 모양이 틀릴 때만 400
const malformed = await logout()
.send({ refreshToken: 12345 })
.expect(400);
expect(body(malformed).error?.code).toBe('VAL_001');
const untouched = tokensOf(await appLogin(ME, '198.18.20.8').expect(200));
const neither = await logout()
.set('Authorization', 'Bearer garbage')
.send({ refreshToken: 'garbage' })
.expect(200);
expect(body(neither)).toEqual({ success: true, data: null });
await logout().expect(200);
// 아무 세션도 끝내지 않았다
await appRefresh(untouched.refreshToken).expect(200);
});
it('앱 로그아웃에 웹 쿠키(access_token · refresh_token)가 함께 와도 웹 세션은 그대로다 — 앱 경로는 쿠키를 보지 않는다', async () => {
const ME = 'app.logout.cookies@e2e.arasystem.test';
const web = await webLogin(ME, '198.18.20.22').expect(200);
const webAccess = cookieValue(setCookie(web, 'access_token'));
const webRefresh = cookieValue(setCookie(web, 'refresh_token'));
const appTokens = tokensOf(
await appLogin(ME, '198.18.20.23').expect(200),
);
await request(server)
.post('/api/auth/app/logout')
.set('Cookie', [
`access_token=${webAccess}`,
`refresh_token=${webRefresh}`,
])
.set('Authorization', bearer(appTokens.accessToken))
.send({})
.expect(200);
await appRefresh(appTokens.refreshToken).expect(401);
// 웹 세션은 끊지 않았다 — 쿠키를 지우지도 않는다
await request(server)
.get('/api/auth/me')
.set('Cookie', `access_token=${webAccess}`)
.expect(200);
await refreshWith(webRefresh).expect(200);
// 쿠키만 오고 본문 · Bearer 가 없으면 아무 세션도 끝내지 않는다
const webOnly = await webLogin(ME, '198.18.20.24').expect(200);
const onlyRefresh = cookieValue(setCookie(webOnly, 'refresh_token'));
const res = await request(server)
.post('/api/auth/app/logout')
.set('Cookie', `refresh_token=${onlyRefresh}`)
.send({})
.expect(200);
expect(res.headers['set-cookie']).toBeUndefined();
await refreshWith(onlyRefresh).expect(200);
});
it('앱 표시가 있는데 세션 id 가 없거나 UUID 가 아닌 액세스 토큰은 401 AUTH_001', async () => {
const ME = 'app.nosid@e2e.arasystem.test';
for (const extra of [{}, { sid: 'not-a-uuid' }]) {
const token = sign(
{ sub: ME, typ: 'access', name: EMPLOYEE.name, cli: 'app', ...extra },
600,
);
const res = await meWith(token).expect(401);
expect(body(res).error?.code).toBe('AUTH_001');
}
});
it('2단계 인증을 켠 직원 — 비밀번호만이면 twoFactorRequired · tokens: null(세션 없음), 코드를 더하면 tokens', async () => {
const ME = 'app.otp@e2e.arasystem.test';
const secret = randomBytes(20);
twoFactorUrls.set(
ME,
buildOtpauthUrl({
issuer: 'ARA SYSTEM',
account: ME,
secret: encodeBase32(secret),
}),
);
try {
const first = await appLogin(ME, '198.18.20.17').expect(200);
expect(dataOf<AppSession>(first)).toEqual({
status: 'twoFactorRequired',
email: null,
name: null,
...UNRESTRICTED,
tokens: null,
});
const second = await appLogin(ME, '198.18.20.18', {
otpCode: otpCodeOf(secret),
}).expect(200);
expect(dataOf<AppSession>(second)).toMatchObject({
status: 'signedIn',
email: ME,
});
await meWith(tokensOf(second).accessToken).expect(200);
} finally {
twoFactorUrls.delete(ME);
}
});
it('제한된 세션도 Bearer 로 같다 — 허용한 경로만, 그 밖은 403 AUTH_010 → 앱에서 비밀번호를 바꾸면 이 앱 세션의 제한이 풀린다', async () => {
const ME = 'app.expired@e2e.arasystem.test';
expiredPasswords.add(ME);
try {
const res = await appLogin(ME, '198.18.20.9').expect(200);
expect(dataOf<AppSession>(res)).toMatchObject({
status: 'signedIn',
restriction: 'passwordChange',
});
const tokens = tokensOf(res);
const as = (test: HttpTest) =>
test.set('Authorization', bearer(tokens.accessToken));
const me = await as(request(server).get('/api/auth/me')).expect(200);
expect(body(me).data).toMatchObject({ restriction: 'passwordChange' });
await as(request(server).get('/api/auth/sessions')).expect(200);
await as(request(server).get('/api/two-factor')).expect(200);
const blocked = await as(request(server).get('/api/mailboxes')).expect(
403,
);
expect(body(blocked).error?.code).toBe('AUTH_010');
await as(request(server).put('/api/auth/password'))
.set('X-Forwarded-For', '198.18.20.9')
.send({
currentPassword: CORRECT_PASSWORD,
newPassword: 'new-password-for-e2e-app',
})
.expect(200);
// 옛 액세스 토큰(rst)으로도 세션 표의 지금 값 — 제한이 풀렸다
await as(request(server).get('/api/mailboxes')).expect(200);
const renewed = await appRefresh(tokens.refreshToken).expect(200);
expect(dataOf<AppSession>(renewed)).toMatchObject(UNRESTRICTED);
} finally {
expiredPasswords.delete(ME);
}
});
it('앱에서 비밀번호를 바꾸면 이 앱 세션(sid)만 남고 웹 · 다른 앱 세션은 끊긴다', async () => {
const ME = 'app.password@e2e.arasystem.test';
const web = await webLogin(ME, '198.18.20.10').expect(200);
const webRefresh = cookieValue(setCookie(web, 'refresh_token'));
const other = tokensOf(await appLogin(ME, '198.18.20.11').expect(200));
const mine = tokensOf(await appLogin(ME, '198.18.20.12').expect(200));
await request(server)
.put('/api/auth/password')
.set('X-Forwarded-For', '198.18.20.12')
.set('Authorization', bearer(mine.accessToken))
.send({
currentPassword: CORRECT_PASSWORD,
newPassword: 'new-password-for-e2e-app-2',
})
.expect(200);
await meWith(mine.accessToken).expect(200);
await appRefresh(mine.refreshToken).expect(200);
await meWith(other.accessToken).expect(401);
await appRefresh(other.refreshToken).expect(401);
await refreshWith(webRefresh).expect(401);
const rows: { revoked_reason: string | null }[] = await dataSource.query(
'SELECT revoked_reason FROM auth_sessions WHERE id = $1',
[sidOf(other.refreshToken)],
);
expect(rows).toEqual([{ revoked_reason: 'password-changed' }]);
});
it('로그인된 기기 — 앱이 부르면 그 앱 세션(sid)이 지금 세션(맨 앞)이고 ARA SYSTEM 앱 · Android, 다른 곳 모두 끊기는 그 앱 세션을 남긴다', async () => {
const ME = 'app.sessions@e2e.arasystem.test';
const web = await webLogin(ME, '198.18.20.13').expect(200);
const webAccess = cookieValue(setCookie(web, 'access_token'));
const appTokens = tokensOf(
await appLogin(ME, '198.18.20.14').expect(200),
);
const appSid = sidOf(appTokens.accessToken);
const listed = await request(server)
.get('/api/auth/sessions')
.set('Authorization', bearer(appTokens.accessToken))
.expect(200);
const page = dataOf<
Page<{
id: string;
isCurrent: boolean;
ip: string | null;
device: { browser: string | null; os: string | null } | null;
}>
>(listed);
expect(page.items[0]).toMatchObject({
id: appSid,
isCurrent: true,
ip: '198.18.20.14',
client: 'app',
device: { browser: 'ARA SYSTEM 앱', os: 'Android' },
});
expect(page.items[1]).toMatchObject({
id: sidOf(webAccess),
isCurrent: false,
client: 'web',
});
expect(page.items.filter(({ isCurrent }) => isCurrent)).toHaveLength(1);
expect(page.pageInfo.total).toBe(2);
const rows: { client: string }[] = await dataSource.query(
'SELECT client FROM auth_sessions WHERE id = ANY($1) ORDER BY client',
[[appSid, sidOf(webAccess)]],
);
expect(rows).toEqual([{ client: 'app' }, { client: 'web' }]);
// 지금 세션은 하나 끊기로 끊지 못한다 (로그아웃을 쓴다)
const self = await request(server)
.delete(`/api/auth/sessions/${appSid}`)
.set('Authorization', bearer(appTokens.accessToken))
.expect(400);
expect(body(self).error?.code).toBe('VAL_001');
const ended = await request(server)
.delete('/api/auth/sessions')
.set('Authorization', bearer(appTokens.accessToken))
.expect(200);
expect(body(ended).data).toEqual({ ended: 1 });
await request(server)
.get('/api/auth/me')
.set('Cookie', `access_token=${webAccess}`)
.expect(401);
await meWith(appTokens.accessToken).expect(200);
});
it('실패 카운터는 웹 · 앱이 함께 센다 — 같은 주소 15분 10회를 웹 5 · 앱 5 로 채우면 맞는 비밀번호도 앱 · 웹 모두 429 REQ_001', async () => {
const ME = 'app.limit@e2e.arasystem.test';
for (let i = 0; i < 5; i += 1) {
const web = await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', `198.18.21.${i}`)
.send({ email: ME, password: 'wrong-password' })
.expect(401);
expect(body(web).error?.code).toBe('AUTH_004');
const appFailure = await request(server)
.post('/api/auth/app/login')
.set('X-Forwarded-For', `198.18.22.${i}`)
.send({ email: ME, password: 'wrong-password' })
.expect(401);
expect(body(appFailure).error?.code).toBe('AUTH_004');
}
const appLocked = await appLogin(ME, '198.18.23.1').expect(429);
expect(body(appLocked).error?.code).toBe('REQ_001');
const webLocked = await webLogin(ME, '198.18.23.2').expect(429);
expect(body(webLocked).error?.code).toBe('REQ_001');
});
it('로그인 기록 — 앱 로그인은 방식 app · 기기 ARA SYSTEM 앱 · Android (성공 · 비밀번호 틀림)', async () => {
const ME = 'app.history@e2e.arasystem.test';
// 비밀번호 틀림은 사내 목록에 있는 주소만 남긴다 — 사내 목록의 답을 직접 정한다(테스트 차례와 상관없게)
const directory = jest
.spyOn(app.get(DirectoryService, { strict: false }), 'hasEmail')
.mockResolvedValue(true);
try {
await request(server)
.post('/api/auth/app/login')
.set('X-Forwarded-For', '198.18.20.15')
.set('User-Agent', APP_UA)
.send({ email: ME, password: 'wrong-password' })
.expect(401);
const appTokens = tokensOf(
await appLogin(ME, '198.18.20.16').expect(200),
);
const res = await request(server)
.get('/api/login-events')
.set('Authorization', bearer(appTokens.accessToken))
.expect(200);
expect(dataOf<Page<Record<string, unknown>>>(res).items).toEqual([
expect.objectContaining({
method: 'app',
isSuccess: true,
isTwoFactor: false,
failureReason: null,
ip: '198.18.20.16',
device: { browser: 'ARA SYSTEM 앱', os: 'Android' },
}),
expect.objectContaining({
method: 'app',
isSuccess: false,
failureReason: 'wrongPassword',
ip: '198.18.20.15',
device: { browser: 'ARA SYSTEM 앱', os: 'Android' },
}),
]);
} finally {
directory.mockRestore();
}
});
it('웹 로그인이 앱 꼴 User-Agent 를 보내도 앱 이름을 받지 않는다 — 로그인된 기기 client web · 브라우저 null, 로그인 기록 webmail', async () => {
const ME = 'app.spoof@e2e.arasystem.test';
const web = await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.18.20.25')
.set('User-Agent', APP_UA)
.send({ email: ME, password: CORRECT_PASSWORD })
.expect(200);
const cookies = [
`access_token=${cookieValue(setCookie(web, 'access_token'))}`,
`refresh_token=${cookieValue(setCookie(web, 'refresh_token'))}`,
];
const sessionsRes = await request(server)
.get('/api/auth/sessions')
.set('Cookie', cookies)
.expect(200);
expect(dataOf<Page<Record<string, unknown>>>(sessionsRes).items).toEqual([
expect.objectContaining({
isCurrent: true,
client: 'web',
device: { browser: null, os: 'Android' },
}),
]);
const events = await request(server)
.get('/api/login-events')
.set('Cookie', cookies)
.expect(200);
expect(dataOf<Page<Record<string, unknown>>>(events).items).toEqual([
expect.objectContaining({
method: 'webmail',
device: { browser: null, os: 'Android' },
}),
]);
});
it('출처 정보가 없는 앱 요청은 출처 검사를 지난다 — 다른 사이트의 Origin 이면 앱 경로도 403 AUTH_003', async () => {
await request(server).post('/api/auth/app/logout').send({}).expect(200);
const evil = await request(server)
.post('/api/auth/app/login')
.set('Origin', 'https://evil.example')
.send({ email: 'app.origin@e2e.arasystem.test', password: 'x' })
.expect(403);
expect(body(evil).error?.code).toBe('AUTH_003');
});
});
describe('출처 검사 (상태 변경 요청)', () => {
const EVIL_ORIGIN = 'https://evil.example';