diff --git a/webapp/api/index.ts b/webapp/api/index.ts index 2ccae13..b881b3a 100644 --- a/webapp/api/index.ts +++ b/webapp/api/index.ts @@ -8,6 +8,7 @@ import { IdpRepository } from '../repository/idp' import cbRoute from './cb' import goRoute from './go' +import loginRoute from './login' import oauthRoute from './oauth' import tokenRoute from './token' @@ -32,6 +33,7 @@ app.use(async (c, next) => { app.route('/token', tokenRoute) app.route('/go', goRoute) app.route('/cb', cbRoute) +app.route('/login', loginRoute) app.route('/oauth', oauthRoute) export default app diff --git a/webapp/api/login/github.ts b/webapp/api/login/github.ts new file mode 100644 index 0000000..3a63023 --- /dev/null +++ b/webapp/api/login/github.ts @@ -0,0 +1,156 @@ +import { zValidator } from '@hono/zod-validator' +import { createAppAuth } from '@octokit/auth-app' +import { Hono } from 'hono' +import { HonoEnv } from 'load-context' +import { Octokit } from 'octokit' +import { binaryToBase64 } from 'utils/convert-bin-base64' +import cookieSessionStorage from 'utils/session.server' +import { z } from 'zod' + +const app = new Hono() + +app.get('/', async c => { + const state = binaryToBase64(crypto.getRandomValues(new Uint8Array(30))) + + const { getSession, commitSession } = cookieSessionStorage(c.env) + const session = await getSession(c.req.raw.headers.get('Cookie')) + session.flash('state', state) + c.header('Set-Cookie', await commitSession(session)) + + // ref: https://docs.github.com/ja/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps + const oauthUrl = new URL('https://github.com/login/oauth/authorize') + const oauthParams = new URLSearchParams() + oauthParams.set('client_id', c.env.GITHUB_OAUTH_ID) + oauthParams.set('redirect_uri', `${c.env.CF_PAGES_URL}/login/github/callback`) + oauthParams.set('scope', 'read:user') + oauthParams.set('state', state) + oauthParams.set('allow_signup', 'false') + + return c.redirect(oauthUrl.toString() + '?' + oauthParams.toString(), 302) +}) + +app.all('/', async c => { + return c.text('method not allowed', 405) +}) + +interface GitHubOAuthTokenResponse { + access_token: string + scope: string + token_type: string +} + +// TODO: cookieSessionStorage の userId 以外は使ってないので消す +app.get( + '/callback', + zValidator('query', z.object({ code: z.string(), state: z.string() })), + async c => { + const { code, state } = c.req.valid('query') + + const { getSession, commitSession } = cookieSessionStorage(c.env) + const session = await getSession(c.req.raw.headers.get('Cookie')) + + if (state !== session.get('state')) { + c.header('Set-Cookie', await commitSession(session)) + return c.text('state mismatch', 400) + } + + const continueTo = session.get('continue_to') + if (!continueTo) { + c.header('Set-Cookie', await commitSession(session)) + return c.text('continue_to not found', 400) + } + + const { access_token } = await fetch( + 'https://github.com/login/oauth/access_token', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: c.env.GITHUB_OAUTH_ID, + client_secret: c.env.GITHUB_OAUTH_SECRET, + code, + }), + }, + ) + .then(res => res.json()) + .catch(() => ({ access_token: null })) + + if (!access_token) { + return c.text('invalid code', 400) + } + + // ----- メンバーの所属判定 ----- // + const userOctokit = new Octokit({ auth: access_token }) + const appOctokit = new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: c.env.GITHUB_APP_ID, + privateKey: atob(c.env.GITHUB_APP_PRIVKEY), + installationId: '41674415', + }, + }) + + const { data: user } = await userOctokit.request('GET /user') + + let isMember = false + try { + const checkIsOrgMemberRes = await appOctokit.request( + 'GET /orgs/{org}/members/{username}', + { + org: 'saitamau-maximum', + username: user.login, + }, + ) + isMember = (checkIsOrgMemberRes.status as number) === 204 + } catch { + isMember = false + } + + if (!isMember) { + // いったん member じゃない場合はログインさせないようにする + // TODO: IdP が出来たらこっちも対応できるようにする + c.header('Set-Cookie', await commitSession(session)) + return c.text('not a member', 403) + } + + // すでになければ DB にユーザー情報を格納 + const oauthConnInfo = await c.var.idpClient.getUserIdByOauthId( + 1, + String(user.id), + ) + if (!oauthConnInfo) { + const uuid = crypto.randomUUID().replaceAll('-', '') + // とりあえず仮情報で埋める + await c.var.idpClient.createUserWithOauth( + { + id: uuid, + display_name: user.login, + profile_image_url: user.avatar_url, + }, + { + user_id: uuid, + provider_id: 1, + provider_user_id: String(user.id), + email: user.email, + name: user.login, + profile_image_url: user.avatar_url, + }, + ) + session.set('user_id', uuid) + } else { + session.set('user_id', oauthConnInfo.user_id) + } + + c.header('Set-Cookie', await commitSession(session)) + return c.redirect(continueTo, 302) + }, +) + +app.all('/callback', async c => { + return c.text('method not allowed', 405) +}) + +export default app diff --git a/webapp/api/login/index.ts b/webapp/api/login/index.ts new file mode 100644 index 0000000..efd011b --- /dev/null +++ b/webapp/api/login/index.ts @@ -0,0 +1,13 @@ +import { Hono } from 'hono' + +import { HonoEnv } from '../../load-context' + +import loginGithubRoute from './github' +import loginIndexRoute from './login' + +const app = new Hono() + +app.route('/', loginIndexRoute) +app.route('/github', loginGithubRoute) + +export default app diff --git a/webapp/api/login/login.ts b/webapp/api/login/login.ts new file mode 100644 index 0000000..ce665c6 --- /dev/null +++ b/webapp/api/login/login.ts @@ -0,0 +1,48 @@ +import { zValidator } from '@hono/zod-validator' +import { Hono } from 'hono' +import { HonoEnv } from 'load-context' +import cookieSessionStorage from 'utils/session.server' +import { z } from 'zod' + +const app = new Hono() + +app.get( + '/', + zValidator( + 'query', + z.object({ + continue_to: z.string().optional(), + }), + ), + async c => { + const { continue_to } = c.req.valid('query') + + const { getSession, commitSession } = cookieSessionStorage(c.env) + const session = await getSession(c.req.raw.headers.get('Cookie')) + session.flash('continue_to', continue_to || '/') + c.header('Set-Cookie', await commitSession(session)) + + // @sor4chi デザインたのんだ + const responseHtml = ` + + + + + ログイン + + +

ログイン

+

ログインしてください

+ GitHub でログイン + + +` + return c.html(responseHtml) + }, +) + +app.all('/', async c => { + return c.text('method not allowed', 405) +}) + +export default app diff --git a/webapp/api/oauth/callback.ts b/webapp/api/oauth/callback.ts index eeec72d..9b46038 100644 --- a/webapp/api/oauth/callback.ts +++ b/webapp/api/oauth/callback.ts @@ -4,6 +4,7 @@ import { token, tokenScope } from 'db/schema' import { Hono } from 'hono' import { HonoEnv } from 'load-context' import { validateAuthToken } from 'utils/auth-token.server' +import { binaryToBase64 } from 'utils/convert-bin-base64' import cookieSessionStorage from 'utils/session.server' import { z } from 'zod' @@ -135,13 +136,11 @@ app.post( }) // code (240bit = 8bit * 30) を生成 - const code = btoa( - String.fromCharCode(...crypto.getRandomValues(new Uint8Array(30))), - ) + const code = binaryToBase64(crypto.getRandomValues(new Uint8Array(30))) // access token (312bit = 8bit * 39) を生成 - const accessToken = btoa( - String.fromCharCode(...crypto.getRandomValues(new Uint8Array(39))), + const accessToken = binaryToBase64( + crypto.getRandomValues(new Uint8Array(39)), ) // DB に格納 diff --git a/webapp/utils/convert-bin-base64.ts b/webapp/utils/convert-bin-base64.ts new file mode 100644 index 0000000..0ba82e5 --- /dev/null +++ b/webapp/utils/convert-bin-base64.ts @@ -0,0 +1,5 @@ +export const base64ToBinary = (base64: string) => + Uint8Array.from(atob(base64), c => c.charCodeAt(0)) + +export const binaryToBase64 = (bin: Uint8Array) => + btoa(String.fromCharCode(...bin))