generated from ttipo/project-base
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:
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user