Files
Yggdrasil/services/authService.js
T
2026-09-02 23:35:54 +02:00

358 lines
11 KiB
JavaScript

const jwt = require("jsonwebtoken")
const utils = require("../modules/utils")
const bcrypt = require("bcryptjs")
const crypto = require("node:crypto")
const userRepository = require("../repositories/userRepository")
const authRepository = require("../repositories/authRepository")
const certsManager = require("../modules/certificatesManager")
const { DefaultError } = require("../errors/errors")
const keys = certsManager.getKeys()
const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
const usernameRegex = /^[a-zA-Z0-9_]{3,16}$/
async function registerUser({ username, password, email, registrationCountry, preferredLanguage, clientIp }) {
const availability = await checkUsernameAvailability(username)
if (availability.status === "DUPLICATE") {
throw new DefaultError(409, "Username taken.", "ForbiddenOperationException")
}
if (availability.status === "NOT_ALLOWED") {
throw new DefaultError(400, availability.message || "Username invalid.", "InvalidUsernameException")
}
if (email) {
try {
await authRepository.getUser(email)
throw new DefaultError(409, "E-Mail taken.", "ForbiddenOperationException")
} catch (error) {
if (error.code !== 404) throw error
}
}
const userRegistration = await authRepository.register(email || "", username, password)
const { uuid } = userRegistration
const resolvedCountry = registrationCountry || await utils.getRegistrationCountryFromIp(clientIp)
await userRepository.addPropertyToPlayer("registrationCountry", resolvedCountry || "UNKNOWN", uuid)
await userRepository.addPropertyToPlayer("userPreferredLanguage", preferredLanguage || "fr-FR", uuid)
return { code: 200, message: "User created successfully", uuid }
}
async function authenticate({ identifier, password, clientToken, 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
}
const passwordValidationProcess = await bcrypt.compare(password, userResult.user.password)
if (!passwordValidationProcess) {
throw new DefaultError(403, "Invalid credentials. Invalid username or password.", "ForbiddenOperationException")
}
delete userResult.user.password
const $clientToken = clientToken || crypto.randomUUID()
const accessToken = jwt.sign({
uuid: userResult.user.uuid,
username: userResult.user.username,
clientToken: $clientToken,
type: "Minecraft"
}, 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 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 {
sessionCheck = await authRepository.getClientSession(previousAccessToken, clientToken)
} catch (error) {
throw new DefaultError(403, "Invalid token or session expired.", "ForbiddenOperationException")
}
const uuid = sessionCheck.session.uuid
const userResult = await authRepository.getUser(uuid, true)
delete userResult.user.password
await authRepository.invalidateClientSession(previousAccessToken, clientToken)
const $clientToken = clientToken || crypto.randomUUID()
const newAccessToken = jwt.sign({
uuid: userResult.user.uuid,
username: userResult.user.username,
clientToken: $clientToken,
type: "Minecraft"
}, keys.authenticationKeys.private, {
subject: userResult.user.uuid,
issuer: "LentiaYggdrasil",
expiresIn: "1d",
algorithm: "RS256"
})
const clientSessionProcess = await authRepository.insertClientSession(newAccessToken, $clientToken, userResult.user.uuid)
const userObject = {
clientToken: clientSessionProcess.clientToken,
accessToken: clientSessionProcess.accessToken,
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 validate({ accessToken, clientToken }) {
if (clientToken !== undefined) {
await authRepository.validateClientSession(accessToken, clientToken)
} else {
await authRepository.validateClientSessionWithoutClientToken(accessToken)
}
return { code: 204 }
}
async function invalidate({ accessToken, clientToken }) {
await authRepository.invalidateClientSession(accessToken, clientToken)
return { code: 204 }
}
async function signout({ uuid }) {
await authRepository.revokeAccessTokens(uuid)
return { code: 204 }
}
async function verifyAccessToken({ accessToken }) {
try {
if (!accessToken) {
throw new DefaultError(400, "Token is missing.")
}
const decoded = jwt.verify(accessToken, keys.authenticationKeys.public, {
algorithms: ["RS256"],
issuer: "LentiaYggdrasil"
})
const clientToken = decoded.clientToken
if (!clientToken) {
throw new DefaultError(403, "Token format invalid (missing clientToken).")
}
try {
await authRepository.validateClientSession(accessToken, clientToken)
} catch (error) {
throw new DefaultError(401, "Session has been revoked or invalidated.")
}
const account = await authRepository.getUser(decoded.sub)
return {
code: 200,
user: {
uuid: decoded.sub,
username: account.user.username
},
session: {
clientToken
}
}
} catch (error) {
if (error instanceof DefaultError) throw error
if (error.name === "TokenExpiredError") {
throw new DefaultError(401, "Token has expired.")
}
throw new DefaultError(500, "Internal Verification Error", error.toString())
}
}
async function checkUsernameAvailability(username) {
if (!usernameRegex.test(username)) {
return {
code: 200,
status: "NOT_ALLOWED",
message: "Invalid characters or length."
}
}
try {
await authRepository.getUser(username)
return {
code: 200,
status: "DUPLICATE",
message: "Username is already in use."
}
} catch (error) {
if (error.code !== 404) throw error
}
const blocklist = await authRepository.getUsernamesRules()
const normalizedUsername = username.toLowerCase()
for (const entry of blocklist) {
if (entry.type === "literal") {
if (normalizedUsername === entry.value) {
return {
code: 200,
status: "NOT_ALLOWED",
message: "Username is reserved."
}
}
}
else if (entry.type === "regex") {
if (entry.pattern.test(username)) {
return {
code: 200,
status: "NOT_ALLOWED",
message: "Username contains forbidden words."
}
}
}
}
return {
code: 200,
status: "AVAILABLE"
}
}
module.exports = {
signout,
validate,
invalidate,
authenticate,
refreshToken,
registerUser,
verifyAccessToken,
checkUsernameAvailability,
authenticateWithoutPassword,
}