Skip to content

Commit

Permalink
/login 実装 + base64 まわり refactor (#48)
Browse files Browse the repository at this point in the history
* feat: implement binary to base64 conversion utility and refactor token generation

* feat: create `/login` route

* fix: 非 Maximum member はログインさせない

* refactor: update TODO comment

* review fix
  • Loading branch information
a01sa01to authored Dec 15, 2024
1 parent 9a38ff7 commit 586ad80
Show file tree
Hide file tree
Showing 6 changed files with 228 additions and 5 deletions.
2 changes: 2 additions & 0 deletions webapp/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
156 changes: 156 additions & 0 deletions webapp/api/login/github.ts
Original file line number Diff line number Diff line change
@@ -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<HonoEnv>()

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<GitHubOAuthTokenResponse>())
.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
13 changes: 13 additions & 0 deletions webapp/api/login/index.ts
Original file line number Diff line number Diff line change
@@ -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<HonoEnv>()

app.route('/', loginIndexRoute)
app.route('/github', loginGithubRoute)

export default app
48 changes: 48 additions & 0 deletions webapp/api/login/login.ts
Original file line number Diff line number Diff line change
@@ -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<HonoEnv>()

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 = `
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ログイン</title>
</head>
<body>
<h1>ログイン</h1>
<p>ログインしてください</p>
<a href="/login/github">GitHub でログイン</a>
</body>
</html>
`
return c.html(responseHtml)
},
)

app.all('/', async c => {
return c.text('method not allowed', 405)
})

export default app
9 changes: 4 additions & 5 deletions webapp/api/oauth/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 に格納
Expand Down
5 changes: 5 additions & 0 deletions webapp/utils/convert-bin-base64.ts
Original file line number Diff line number Diff line change
@@ -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))

0 comments on commit 586ad80

Please sign in to comment.