Add Discord OAuth2 account linking and login support
Introduces Discord OAuth2 integration for account association and login, including new routes for linking, unlinking, and authenticating via Discord. Adds supporting services, repositories, and schema validation for the OAuth2 flow. Refactors database schema and queries for consistency, and updates dependencies to include required OAuth2 libraries.
This commit is contained in:
@@ -139,13 +139,13 @@ async function logPlayerAction(playerUuid, actionCode) {
|
||||
module.exports = {
|
||||
loginAdmin,
|
||||
uploadCape,
|
||||
deleteCape,
|
||||
registerAdmin,
|
||||
hasPermission,
|
||||
getAdminProfile,
|
||||
grantPermission,
|
||||
logPlayerAction,
|
||||
revokePermission,
|
||||
checkAdminAccess,
|
||||
deleteCape,
|
||||
logPlayerAction,
|
||||
changeAdminPassword,
|
||||
changeAdminPassword
|
||||
}
|
||||
@@ -61,6 +61,7 @@ async function authenticate({ identifier, password, clientToken, requireUser })
|
||||
uuid: userResult.user.uuid,
|
||||
username: userResult.user.username,
|
||||
clientToken: $clientToken,
|
||||
type: "Minecraft"
|
||||
}, keys.authenticationKeys.private, {
|
||||
subject: userResult.user.uuid,
|
||||
issuer: "LentiaYggdrasil",
|
||||
@@ -105,6 +106,70 @@ async function authenticate({ identifier, password, clientToken, requireUser })
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateWithoutPassword({ identifier, requireUser }) {
|
||||
let userResult
|
||||
try {
|
||||
userResult = await authRepository.getUser(identifier, true)
|
||||
} catch (error) {
|
||||
if (error.code === 404) {
|
||||
throw new DefaultError(403, "Invalid credentials. Invalid username or password.", "ForbiddenOperationException")
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
delete userResult.user.password
|
||||
|
||||
const $clientToken = crypto.randomUUID()
|
||||
const accessToken = jwt.sign({
|
||||
uuid: userResult.user.uuid,
|
||||
username: userResult.user.username,
|
||||
clientToken: $clientToken,
|
||||
type: "Minecraft_OAuth2"
|
||||
}, keys.authenticationKeys.private, {
|
||||
subject: userResult.user.uuid,
|
||||
issuer: "LentiaYggdrasil",
|
||||
expiresIn: "1d",
|
||||
algorithm: "RS256"
|
||||
})
|
||||
|
||||
const clientSessionProcess = await authRepository.insertClientSession(accessToken, $clientToken, userResult.user.uuid)
|
||||
|
||||
const userObject = {
|
||||
clientToken: clientSessionProcess.clientToken,
|
||||
accessToken: clientSessionProcess.accessToken,
|
||||
availableProfiles: [{
|
||||
name: userResult.user.username,
|
||||
id: userResult.user.uuid,
|
||||
}],
|
||||
selectedProfile: {
|
||||
name: userResult.user.username,
|
||||
id: userResult.user.uuid,
|
||||
}
|
||||
}
|
||||
|
||||
if (requireUser) {
|
||||
try {
|
||||
const propertiesRequest = await authRepository.getPlayerProperties(userResult.user.uuid)
|
||||
userObject.user = {
|
||||
username: userResult.user.username,
|
||||
properties: propertiesRequest.properties
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 404) throw error
|
||||
userObject.user = {
|
||||
username: userResult.user.username,
|
||||
properties: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
response: userObject
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function refreshToken({ previousAccessToken, clientToken, requireUser }) {
|
||||
let sessionCheck
|
||||
try {
|
||||
@@ -125,6 +190,7 @@ async function refreshToken({ previousAccessToken, clientToken, requireUser }) {
|
||||
uuid: userResult.user.uuid,
|
||||
username: userResult.user.username,
|
||||
clientToken: $clientToken,
|
||||
type: "Minecraft"
|
||||
}, keys.authenticationKeys.private, {
|
||||
subject: userResult.user.uuid,
|
||||
issuer: "LentiaYggdrasil",
|
||||
@@ -281,9 +347,10 @@ module.exports = {
|
||||
signout,
|
||||
validate,
|
||||
invalidate,
|
||||
registerUser,
|
||||
authenticate,
|
||||
refreshToken,
|
||||
registerUser,
|
||||
verifyAccessToken,
|
||||
checkUsernameAvailability
|
||||
checkUsernameAvailability,
|
||||
authenticateWithoutPassword,
|
||||
}
|
||||
134
services/oauth2Service.js
Normal file
134
services/oauth2Service.js
Normal file
@@ -0,0 +1,134 @@
|
||||
const { DiscordOAuth2 } = require("@mgalacyber/discord-oauth2")
|
||||
const oauth2Repository = require("../repositories/oauth2Repository")
|
||||
const userService = require("./userService")
|
||||
const authService = require("./authService")
|
||||
const { StateTypes, Scopes, PromptTypes, ResponseCodeTypes } = require("@mgalacyber/discord-oauth2")
|
||||
const { DefaultError } = require("../errors/errors")
|
||||
|
||||
const oauth2_association = new DiscordOAuth2({
|
||||
clientId: process.env.DISCORD_CLIENT_ID,
|
||||
clientSecret: process.env.DISCORD_CLIENT_SECRET,
|
||||
redirectUri: process.env.DISCORD_ASSOCIATION_REDIRECT_URL
|
||||
})
|
||||
|
||||
const oauth2_login = new DiscordOAuth2({
|
||||
clientId: process.env.DISCORD_CLIENT_ID,
|
||||
clientSecret: process.env.DISCORD_CLIENT_SECRET,
|
||||
redirectUri: process.env.DISCORD_LOGIN_REDIRECT_URL
|
||||
})
|
||||
|
||||
async function generateAssociationDiscordURL(playerUuid) {
|
||||
const redirectObject = await oauth2_association.GenerateOAuth2Url({
|
||||
state: StateTypes.UserAuth,
|
||||
scope: [
|
||||
Scopes.Identify
|
||||
],
|
||||
prompt: PromptTypes.Consent,
|
||||
responseCode: ResponseCodeTypes.Code,
|
||||
})
|
||||
await oauth2Repository.createLinkAttempt(redirectObject.state, playerUuid)
|
||||
return redirectObject
|
||||
}
|
||||
|
||||
async function handleAssociationCallback(provider, code, state) {
|
||||
const playerUuid = await oauth2Repository.popLinkAttempt(state)
|
||||
if (!playerUuid) {
|
||||
throw new DefaultError(400, "Invalid or expired session state.", "InvalidStateError")
|
||||
}
|
||||
|
||||
let isProviderAlreadyLinked = false
|
||||
|
||||
try {
|
||||
await userService.getPlayerProperty(playerUuid, `${provider}Id`)
|
||||
isProviderAlreadyLinked = true
|
||||
} catch (error) {
|
||||
if (error.code !== 404) throw error
|
||||
}
|
||||
|
||||
if (isProviderAlreadyLinked) {
|
||||
throw new DefaultError(409, `Account from ${provider} already linked to that player`, "AlreadyLinkedException")
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResponse = await oauth2_association.GetAccessToken(code)
|
||||
const userProfile = await oauth2_association.UserDataSchema.GetUserProfile(tokenResponse.accessToken)
|
||||
if (!userProfile || !userProfile.id) {
|
||||
throw new DefaultError(500, `Failed to retrieve ${provider} profile.`)
|
||||
}
|
||||
|
||||
await userService.addPlayerProperty(playerUuid, `${provider}Id`, userProfile.id)
|
||||
return {
|
||||
code: 200,
|
||||
message: "Account linked successfully",
|
||||
provider: {
|
||||
id: userProfile.id,
|
||||
username: userProfile.username
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DefaultError) throw error
|
||||
throw new DefaultError(500, `${provider} authentication failed: + ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkAccount(provider, playerUuid) {
|
||||
try {
|
||||
const property = await userService.getPlayerProperty(playerUuid, `${provider}Id`).catch(() => null)
|
||||
if (!property) {
|
||||
throw new DefaultError(404, `No ${provider} account linked to this player.`, "NotLinkedError")
|
||||
}
|
||||
|
||||
const success = await oauth2Repository.unlinkProviderAccount(provider, playerUuid)
|
||||
if (!success) {
|
||||
throw new DefaultError(500, "Failed to unlink the account. Please try again.")
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: `${provider} account successfully unlinked.`
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DefaultError) throw error;
|
||||
throw new DefaultError(500, "An error occurred during unlinking: " + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function generateLoginDiscordURL() {
|
||||
const redirectObject = await oauth2_login.GenerateOAuth2Url({
|
||||
state: StateTypes.UserAuth,
|
||||
scope: [
|
||||
Scopes.Identify
|
||||
],
|
||||
prompt: PromptTypes.Consent,
|
||||
responseCode: ResponseCodeTypes.Code,
|
||||
})
|
||||
return redirectObject
|
||||
}
|
||||
|
||||
async function handleLoginCallback(provider, code, requestUser) {
|
||||
try {
|
||||
const tokenResponse = await oauth2_login.GetAccessToken(code)
|
||||
const userProfile = await oauth2_login.UserDataSchema.GetUserProfile(tokenResponse.accessToken)
|
||||
if (!userProfile || !userProfile.id) {
|
||||
throw new DefaultError(500, `Failed to retrieve ${provider} profile.`)
|
||||
}
|
||||
|
||||
const propertyObject = await userService.getPlayerPropertyByValue(`${provider}Id`, userProfile.id)
|
||||
return await authService.authenticateWithoutPassword({ identifier: propertyObject.property.uuid, requireUser: requestUser || true })
|
||||
|
||||
} catch (error) {
|
||||
if (error.code == 404) {
|
||||
throw new DefaultError(404, `No ${provider} account linked to any player.`, "NotLinkedError")
|
||||
}
|
||||
if (error instanceof DefaultError) throw error
|
||||
throw new DefaultError(500, `${provider} authentication failed: + ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
unlinkAccount,
|
||||
handleLoginCallback,
|
||||
generateLoginDiscordURL,
|
||||
handleAssociationCallback,
|
||||
generateAssociationDiscordURL
|
||||
}
|
||||
@@ -29,6 +29,10 @@ async function getPlayerProperty(uuid, key) {
|
||||
return await userRepository.getPlayerProperty(key, uuid)
|
||||
}
|
||||
|
||||
async function getPlayerPropertyByValue(key, value) {
|
||||
return await userRepository.getPlayerPropertyByValue(key, value)
|
||||
}
|
||||
|
||||
async function addPlayerProperty(uuid, key, value) {
|
||||
return await userRepository.addPropertyToPlayer(key, value, uuid)
|
||||
}
|
||||
@@ -561,6 +565,7 @@ module.exports = {
|
||||
getPlayerCertificate,
|
||||
savePlayerCertificate,
|
||||
clearAllPlayerActions,
|
||||
getPlayerPropertyByValue,
|
||||
getPlayerNameChangeStatus,
|
||||
getPlayerUsernamesHistory,
|
||||
deleteExpiredCertificates,
|
||||
|
||||
Reference in New Issue
Block a user