mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-05 19:20:12 +08:00
Merge pull request #33 from linux-odyssey/feat-google-auth
Feat google auth
This commit is contained in:
25 files changed
+247
-21
No files matched your search
@@ -37,6 +37,9 @@ jobs:
|
||||
run: docker-compose -f docker-compose.testing.yml build helloworld
|
||||
|
||||
- name: Run Cypress Tests
|
||||
env:
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
run: docker-compose -f docker-compose.testing.yml run cypress
|
||||
|
||||
- name: Tear Down Test Environment
|
||||
|
||||
@@ -19,6 +19,10 @@ Run everything (frontend, backend, db, swagger, quests):
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
|
||||
If you want to enable social login, you should have OAuth client token in a `.env` file:
|
||||
|
||||
docker compose --env-file .env up -d
|
||||
|
||||
## Testing
|
||||
|
||||
Run Cypress locally, using the current development containers:
|
||||
@@ -42,6 +46,17 @@ Run Cypress in container, create brand-new containers along with it:
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
To enable social login, you should have OAuth client token in a `.env` file:
|
||||
You can copy from `example.env`
|
||||
|
||||
# .env
|
||||
GOOGLE_CLIENT_ID=...
|
||||
GOOGLE_CLIENT_SECRET=...
|
||||
|
||||
To enable it:
|
||||
|
||||
docker compose --env-file .env -f docker-compose.prod.yml up -d
|
||||
|
||||
## Standalone App (without Docker)
|
||||
|
||||
Source: `app/`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { login, register } from '../utils/auth'
|
||||
import api from '../utils/api'
|
||||
@@ -13,6 +13,19 @@ const emailInput = ref(null)
|
||||
const password = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const availableMethods = ref({})
|
||||
onMounted(async () => {
|
||||
const res = await api.get('/auth/available-methods')
|
||||
availableMethods.value = res.data
|
||||
})
|
||||
|
||||
const hasSocialLogins = computed(() => {
|
||||
// There is any key other than 'local' is true
|
||||
return Object.keys(availableMethods.value).some(
|
||||
(method) => method !== 'local' && availableMethods.value[method]
|
||||
)
|
||||
})
|
||||
|
||||
const success = () => {
|
||||
router.push({ name: 'game' })
|
||||
}
|
||||
@@ -58,7 +71,6 @@ async function check() {
|
||||
}
|
||||
|
||||
function handleRegister() {
|
||||
console.log('register', username.value, email.value, password.value)
|
||||
register(username.value, email.value, password.value)
|
||||
.then((isSuccess) => {
|
||||
if (isSuccess) {
|
||||
@@ -167,6 +179,19 @@ const goBack = () => {
|
||||
>
|
||||
<span>Back to Login</span>
|
||||
</button>
|
||||
<div v-if="hasSocialLogins">
|
||||
<hr class="my-8" />
|
||||
<p class="flex justify-center font-semibold text-text text-base">
|
||||
Log in with social account
|
||||
</p>
|
||||
<a
|
||||
v-if="availableMethods.google"
|
||||
class="inline-flex justify-center rounded-lg text-sm font-black py-2 mt-3 bg-icon-google text-text w-full"
|
||||
href="/api/v1/auth/google"
|
||||
>
|
||||
<span>Google</span>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -46,7 +46,7 @@ function resizeScreen() {
|
||||
// 窗口大小改变时,触发xterm的resize方法使自适应
|
||||
fitAddon.fit()
|
||||
} catch (e) {
|
||||
console.log('e', e.message)
|
||||
console.error('e', e.message)
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', resizeScreen)
|
||||
|
||||
@@ -28,7 +28,6 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
console.log(to, from)
|
||||
const loggedIn = await isLoggedIn()
|
||||
// Check if the route requires authentication
|
||||
if (to.meta.requiresAuth && !loggedIn) {
|
||||
|
||||
@@ -8,8 +8,7 @@ export async function isLoggedIn() {
|
||||
|
||||
export const login = async (username, password) => {
|
||||
try {
|
||||
const res = await api.post('/auth/login', { username, password })
|
||||
console.log(res.data)
|
||||
await api.post('/auth/login', { username, password })
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err.response?.status === 401) {
|
||||
|
||||
@@ -38,7 +38,6 @@ class SessionManager {
|
||||
}
|
||||
|
||||
handleHintUpdate(event) {
|
||||
console.log(event)
|
||||
this.hints.value = [...this.hints.value, ...event]
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ export default {
|
||||
border: {
|
||||
DEFAULT: '#526B4E',
|
||||
},
|
||||
icon: {
|
||||
google: '#DB4437',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+2
-2
@@ -8,11 +8,11 @@ export default defineConfig({
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.API_TARGET || 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
changeOrigin: false,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: process.env.API_TARGET || 'ws://localhost:3000',
|
||||
changeOrigin: true,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -20,6 +20,8 @@ services:
|
||||
- players
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- MONGO_URL=mongodb://user:user-password@db:27017/odyssey-test
|
||||
- DOCKER_NETWORK=linux-odyssey-players
|
||||
@@ -39,6 +41,8 @@ services:
|
||||
- MONGO_USER=user
|
||||
- MONGO_PASS=user-password
|
||||
- MONGO_DB=odyssey-test
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
|
||||
base:
|
||||
image: lancatlin/linux-odyssey:base
|
||||
|
||||
@@ -39,6 +39,8 @@ services:
|
||||
environment:
|
||||
- MONGO_URL=mongodb://user:user-password@db-test:27017/odyssey-test
|
||||
- DOCKER_NETWORK=linux-odyssey-players
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
|
||||
db-test:
|
||||
image: mongo
|
||||
|
||||
@@ -30,6 +30,8 @@ services:
|
||||
- MONGO_URL=mongodb://db:27017/odyssey-test
|
||||
- DOCKER_NETWORK=linux-odyssey-players
|
||||
- HOST_PWD=${PWD}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
db:
|
||||
image: mongo
|
||||
networks:
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
inspect() {
|
||||
# default to app, allow overriding by $1
|
||||
SERVICE=${1:-app}
|
||||
SERVICE=${1:-backend}
|
||||
echo "Running docker-compose logs for service [$SERVICE]..."
|
||||
docker compose logs $SERVICE -f --no-log-prefix
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
@@ -22,6 +22,7 @@
|
||||
"mongoose": "^7.4.0",
|
||||
"node-schedule": "^2.1.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-google-oidc": "^0.1.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"socket.io": "^4.7.1",
|
||||
|
||||
@@ -79,7 +79,7 @@ export async function register(req, res, next) {
|
||||
if (await User.exists({ email })) {
|
||||
res.status(409).json({
|
||||
type: 'email',
|
||||
message: 'username already exists',
|
||||
message: 'email already exists',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
import passport from 'passport'
|
||||
|
||||
import enabledMethods from '../../auth/passport.js'
|
||||
|
||||
import {
|
||||
register,
|
||||
checkUsername,
|
||||
@@ -26,4 +28,19 @@ router.get('/check-username', checkUsername)
|
||||
|
||||
router.get('/check-session', checkSession)
|
||||
|
||||
router.get('/available-methods', (req, res) => {
|
||||
res.json(enabledMethods)
|
||||
})
|
||||
|
||||
if (enabledMethods.google) {
|
||||
router.get('/google', passport.authenticate('google'))
|
||||
router.get(
|
||||
'/google/callback',
|
||||
passport.authenticate('google', {
|
||||
successRedirect: '/',
|
||||
failureRedirect: '/login',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export default router
|
||||
@@ -27,8 +27,6 @@ export function pushToSession(sessionId, event, ...args) {
|
||||
}
|
||||
|
||||
async function connectClient(socket) {
|
||||
console.log('Connected to the client.')
|
||||
|
||||
const user = socket.request.session?.passport?.user
|
||||
if (!user) {
|
||||
socket.send('User not found.')
|
||||
@@ -85,7 +83,6 @@ async function connectClient(socket) {
|
||||
listenToSession(session.id, socketCallback)
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Disconnected from the client.')
|
||||
stream.socket.write('exit\n')
|
||||
removeFromSession(session.id, socketCallback)
|
||||
stream.destroy()
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import GoogleStrategy from 'passport-google-oidc'
|
||||
|
||||
import config from '../config.js'
|
||||
import User from '../models/user.js'
|
||||
|
||||
function verify(issuer, profile, cb) {
|
||||
process.nextTick(async () => {
|
||||
const email = profile.emails[0]?.value
|
||||
if (!email) {
|
||||
cb(new Error('No email found'))
|
||||
return
|
||||
}
|
||||
|
||||
let user = await User.findOne({ 'google.id': profile.id })
|
||||
if (!user) {
|
||||
user = await User.findOne({ email })
|
||||
const google = {
|
||||
id: profile.id,
|
||||
displayName: profile.displayName,
|
||||
}
|
||||
if (!user) {
|
||||
user = new User({
|
||||
email,
|
||||
google,
|
||||
})
|
||||
} else if (user.google?.id) {
|
||||
cb(new Error('Email already used and binded to another account'))
|
||||
return
|
||||
} else {
|
||||
user.google = google
|
||||
}
|
||||
await user.save()
|
||||
}
|
||||
|
||||
cb(null, user)
|
||||
})
|
||||
}
|
||||
|
||||
const enabled = config.google.clientID && config.google.clientSecret
|
||||
|
||||
export default enabled
|
||||
? new GoogleStrategy(
|
||||
{
|
||||
clientID: config.google.clientID,
|
||||
clientSecret: config.google.clientSecret,
|
||||
callbackURL: '/api/v1/auth/google/callback',
|
||||
scope: ['profile', 'email'],
|
||||
},
|
||||
verify
|
||||
)
|
||||
: null
|
||||
@@ -1,9 +1,21 @@
|
||||
import passport from 'passport'
|
||||
import PasswordStrategy from './passwordStrategy.js'
|
||||
import JwtStrategy from './jwtStrategy.js'
|
||||
import passwordStrategy from './passwordStrategy.js'
|
||||
import jwtStrategy from './jwtStrategy.js'
|
||||
import googleStrategy from './googleStrategy.js'
|
||||
|
||||
passport.use(PasswordStrategy)
|
||||
passport.use(JwtStrategy)
|
||||
passport.use(passwordStrategy)
|
||||
passport.use(jwtStrategy)
|
||||
|
||||
const enabledMethods = {
|
||||
local: true,
|
||||
google: googleStrategy !== null,
|
||||
}
|
||||
|
||||
console.log('Enabled login methods', enabledMethods)
|
||||
|
||||
if (enabledMethods.google) {
|
||||
passport.use(googleStrategy)
|
||||
}
|
||||
|
||||
passport.serializeUser((user, done) => {
|
||||
process.nextTick(() => {
|
||||
@@ -20,3 +32,5 @@ passport.deserializeUser((user, done) => {
|
||||
done(null, user)
|
||||
})
|
||||
})
|
||||
|
||||
export default enabledMethods
|
||||
@@ -10,4 +10,8 @@ export default {
|
||||
hostPwd: get('HOST_PWD', ''),
|
||||
containerExpiry: get('EXPIRY', 1000 * 60 * 60),
|
||||
sessionMaxAge: get('SESSION_MAX_AGE', 1000 * 60 * 60 * 24 * 7),
|
||||
google: {
|
||||
clientID: get('GOOGLE_CLIENT_ID', ''),
|
||||
clientSecret: get('GOOGLE_CLIENT_SECRET', ''),
|
||||
},
|
||||
}
|
||||
@@ -4,7 +4,8 @@ const User = model('User', {
|
||||
username: {
|
||||
type: String,
|
||||
unique: true,
|
||||
required: true,
|
||||
required: false, // should be required,
|
||||
// but need to let social login users to pick one after login
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
@@ -12,6 +13,10 @@ const User = model('User', {
|
||||
required: true,
|
||||
},
|
||||
hashedPassword: String,
|
||||
google: {
|
||||
id: String,
|
||||
displayName: String,
|
||||
},
|
||||
})
|
||||
|
||||
export default User
|
||||
@@ -319,6 +319,30 @@ paths:
|
||||
type: string
|
||||
example: Username is already taken
|
||||
|
||||
/auth/available-methods:
|
||||
get:
|
||||
summary: Get available login methods
|
||||
responses:
|
||||
'200':
|
||||
description: Available login methods
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
local:
|
||||
type: boolean
|
||||
example: true
|
||||
github:
|
||||
type: boolean
|
||||
example: true
|
||||
google:
|
||||
type: boolean
|
||||
example: true
|
||||
facebook:
|
||||
type: boolean
|
||||
example: true
|
||||
|
||||
/auth/check-session:
|
||||
get:
|
||||
summary: Check if the session is valid
|
||||
@@ -334,6 +358,36 @@ paths:
|
||||
type: boolean
|
||||
example: true
|
||||
|
||||
/auth/google:
|
||||
get:
|
||||
summary: Initiate OAuth2 login with Google
|
||||
operationId: initiateOAuth
|
||||
security:
|
||||
- GoogleOAuth: []
|
||||
responses:
|
||||
302:
|
||||
description: Redirect to Google's OAuth 2.0 authorization endpoint
|
||||
500:
|
||||
description: Internal Server Error
|
||||
/auth/google/callback:
|
||||
get:
|
||||
summary: OAuth2 callback endpoint
|
||||
operationId: oauthCallback
|
||||
parameters:
|
||||
- name: code
|
||||
in: query
|
||||
description: Authorization code from Google
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: Successful Operation
|
||||
400:
|
||||
description: Bad Request
|
||||
500:
|
||||
description: Internal Server Error
|
||||
|
||||
/quests:
|
||||
get:
|
||||
summary: Get quests list
|
||||
@@ -571,3 +625,15 @@ components:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: connect.sid
|
||||
|
||||
GoogleOAuth:
|
||||
type: oauth2
|
||||
flows:
|
||||
authorizationCode:
|
||||
authorizationUrl: https://accounts.google.com/o/oauth2/auth
|
||||
tokenUrl: https://accounts.google.com/o/oauth2/token
|
||||
scopes:
|
||||
# Define your scopes here
|
||||
openid: OpenID
|
||||
profile: User profile
|
||||
email: User email
|
||||
@@ -33,12 +33,10 @@ test('check valid email', () => {
|
||||
]
|
||||
|
||||
validEmails.forEach((email) => {
|
||||
console.log(email)
|
||||
expect(isValidEmail(email)).toBe(true)
|
||||
})
|
||||
|
||||
invalidEmails.forEach((email) => {
|
||||
console.log(email)
|
||||
expect(isValidEmail(email)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -4489,6 +4489,11 @@ nth-check@^2.0.1:
|
||||
dependencies:
|
||||
boolbase "^1.0.0"
|
||||
|
||||
oauth@0.9.x:
|
||||
version "0.9.15"
|
||||
resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
|
||||
integrity sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==
|
||||
|
||||
object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||
@@ -4659,6 +4664,13 @@ parseurl@~1.3.3:
|
||||
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
||||
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
|
||||
|
||||
passport-google-oidc@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/passport-google-oidc/-/passport-google-oidc-0.1.0.tgz#585790ba03864c0238cb89912d7e2a958086b5b0"
|
||||
integrity sha512-/TtFXaWvmubm5kXNoJMyzBfxhnZ0lnBPA6w6rmQMP9klmHZf0ArE8IrIEt3yAHoiDzGx4eTO7YasKQFbPsNtVA==
|
||||
dependencies:
|
||||
passport-openidconnect "0.1.x"
|
||||
|
||||
passport-jwt@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/passport-jwt/-/passport-jwt-4.0.1.tgz#c443795eff322c38d173faa0a3c481479646ec3d"
|
||||
@@ -4674,6 +4686,14 @@ passport-local@^1.0.0:
|
||||
dependencies:
|
||||
passport-strategy "1.x.x"
|
||||
|
||||
passport-openidconnect@0.1.x:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/passport-openidconnect/-/passport-openidconnect-0.1.1.tgz#83921ff5f87f634079f65262dada834af1972244"
|
||||
integrity sha512-r0QJiWEzwCg2MeCIXVP5G6YxVRqnEsZ2HpgKRthZ9AiQHJrgGUytXpsdcGF9BRwd3yMrEesb/uG/Yxb86rrY0g==
|
||||
dependencies:
|
||||
oauth "0.9.x"
|
||||
passport-strategy "1.x.x"
|
||||
|
||||
passport-strategy@1.x.x, passport-strategy@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4"
|
||||
|
||||
Reference in new issue
Block a user