Add OAuth integration for multiple providers and implement user linking
Some checks failed
Deploy to production / deploy (push) Failing after 49s
Some checks failed
Deploy to production / deploy (push) Failing after 49s
- Created OAuth credentials setup guide for Google, Microsoft, Keycloak, ORY, and ZITADEL. - Added migration for oauth_identity table to store OAuth identities linked to users. - Implemented OAuthIdentity model for managing OAuth identities in the database. - Developed oauthService to handle OAuth login, user creation, and identity linking. - Created OAuthCallbackView and OAuthUserCallbackView components for handling OAuth responses in the frontend. - Added error handling and user feedback during the OAuth process.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import * as userService from '../services/authService.js';
|
||||
import * as oauthService from '../services/oauthService.js';
|
||||
|
||||
class AuthController {
|
||||
constructor() {
|
||||
@@ -7,6 +8,13 @@ class AuthController {
|
||||
this.forgotPassword = this.forgotPassword.bind(this);
|
||||
this.activateAccount = this.activateAccount.bind(this);
|
||||
this.logout = this.logout.bind(this);
|
||||
this.oauthProviders = this.oauthProviders.bind(this);
|
||||
this.oauthStart = this.oauthStart.bind(this);
|
||||
this.oauthExchange = this.oauthExchange.bind(this);
|
||||
this.oauthUserIdentities = this.oauthUserIdentities.bind(this);
|
||||
this.oauthUserStart = this.oauthUserStart.bind(this);
|
||||
this.oauthUserExchange = this.oauthUserExchange.bind(this);
|
||||
this.oauthUserRemove = this.oauthUserRemove.bind(this);
|
||||
}
|
||||
|
||||
async register(req, res) {
|
||||
@@ -43,6 +51,133 @@ class AuthController {
|
||||
res.status(200).json({ result: 'loggedout' });
|
||||
}
|
||||
|
||||
async oauthProviders(req, res) {
|
||||
try {
|
||||
const providers = await oauthService.getOAuthProviders();
|
||||
res.status(200).json({ providers });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async oauthStart(req, res) {
|
||||
const { provider } = req.params;
|
||||
try {
|
||||
const redirectTo = await oauthService.startOAuthLogin({ providerSlug: provider });
|
||||
res.redirect(302, redirectTo.toString());
|
||||
} catch (error) {
|
||||
const status = error.message === 'providernotconfigured' ? 503 : 500;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async oauthExchange(req, res) {
|
||||
const { code, state } = req.body;
|
||||
try {
|
||||
const result = await oauthService.exchangeOAuthLogin({ code, state });
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
const knownErrors = new Set([
|
||||
'oauthcodemissing',
|
||||
'oauthstatemissing',
|
||||
'oauthsubjectmissing',
|
||||
'providernotconfigured',
|
||||
'oauthidentityconflict'
|
||||
]);
|
||||
const status = knownErrors.has(error.message) ? 400 : 500;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async oauthUserIdentities(req, res) {
|
||||
const { userid: hashedUserId } = req.headers;
|
||||
try {
|
||||
const User = (await import('../models/community/user.js')).default;
|
||||
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'usernotfound' });
|
||||
}
|
||||
|
||||
const identities = await oauthService.getUserOAuthIdentities(user.id);
|
||||
res.status(200).json({ identities });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async oauthUserStart(req, res) {
|
||||
const { userid: hashedUserId } = req.headers;
|
||||
const { provider } = req.params;
|
||||
try {
|
||||
const User = (await import('../models/community/user.js')).default;
|
||||
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'usernotfound' });
|
||||
}
|
||||
|
||||
const redirectTo = await oauthService.startOAuthLoginForUser({
|
||||
userId: user.id,
|
||||
providerSlug: provider
|
||||
});
|
||||
res.redirect(302, redirectTo.toString());
|
||||
} catch (error) {
|
||||
const status = error.message === 'providernotconfigured' ? 503 : 500;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async oauthUserExchange(req, res) {
|
||||
const { userid: hashedUserId } = req.headers;
|
||||
const { code, state } = req.body;
|
||||
try {
|
||||
const User = (await import('../models/community/user.js')).default;
|
||||
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'usernotfound' });
|
||||
}
|
||||
|
||||
const result = await oauthService.exchangeOAuthLoginForUser({
|
||||
userId: user.id,
|
||||
code,
|
||||
state
|
||||
});
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
const knownErrors = new Set([
|
||||
'oauthcodemissing',
|
||||
'oauthstatemissing',
|
||||
'oauthsubjectmissing',
|
||||
'providernotconfigured',
|
||||
'oauthuseridmismatch',
|
||||
'oauthidentityalreadylinked'
|
||||
]);
|
||||
const status = knownErrors.has(error.message) ? 400 : 500;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async oauthUserRemove(req, res) {
|
||||
const { userid: hashedUserId } = req.headers;
|
||||
const { identityId } = req.params;
|
||||
try {
|
||||
const User = (await import('../models/community/user.js')).default;
|
||||
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'usernotfound' });
|
||||
}
|
||||
|
||||
const result = await oauthService.removeOAuthIdentity({
|
||||
userId: user.id,
|
||||
identityId: parseInt(identityId, 10)
|
||||
});
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
const status = error.message === 'forbidden' ? 403 : 500;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async forgotPassword(req, res) {
|
||||
const { email } = req.body;
|
||||
try {
|
||||
|
||||
@@ -19,3 +19,20 @@ DB_PASS=
|
||||
#
|
||||
# Optional (Defaults siehe utils/sequelize.js)
|
||||
# DB_CONNECT_TIMEOUT_MS=30000
|
||||
|
||||
# OAuth / OpenID Connect
|
||||
# FRONTEND_URL muss auf die öffentliche Frontend-URL zeigen, damit der Provider nach dem Login
|
||||
# korrekt auf den Callback im SPA zurückspringen kann.
|
||||
# OAUTH_GOOGLE_CLIENT_ID=
|
||||
# OAUTH_GOOGLE_CLIENT_SECRET=
|
||||
# OAUTH_MICROSOFT_CLIENT_ID=
|
||||
# OAUTH_MICROSOFT_CLIENT_SECRET=
|
||||
# OAUTH_KEYCLOAK_ISSUER=
|
||||
# OAUTH_KEYCLOAK_CLIENT_ID=
|
||||
# OAUTH_KEYCLOAK_CLIENT_SECRET=
|
||||
# OAUTH_ORY_ISSUER=
|
||||
# OAUTH_ORY_CLIENT_ID=
|
||||
# OAUTH_ORY_CLIENT_SECRET=
|
||||
# OAUTH_ZITADEL_ISSUER=
|
||||
# OAUTH_ZITADEL_CLIENT_ID=
|
||||
# OAUTH_ZITADEL_CLIENT_SECRET=
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable(
|
||||
{ schema: 'community', tableName: 'oauth_identity' },
|
||||
{
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: { schema: 'community', tableName: 'user' },
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
provider: {
|
||||
type: Sequelize.STRING(64),
|
||||
allowNull: false,
|
||||
},
|
||||
issuer: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
subject: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
email: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await queryInterface.addIndex(
|
||||
{ schema: 'community', tableName: 'oauth_identity' },
|
||||
['provider', 'subject'],
|
||||
{ unique: true, name: 'oauth_identity_provider_subject_uniq' }
|
||||
);
|
||||
|
||||
await queryInterface.addIndex(
|
||||
{ schema: 'community', tableName: 'oauth_identity' },
|
||||
['user_id'],
|
||||
{ name: 'oauth_identity_user_idx' }
|
||||
);
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.dropTable({ schema: 'community', tableName: 'oauth_identity' });
|
||||
},
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import Room from './chat/room.js';
|
||||
import User from './community/user.js';
|
||||
import UserParam from './community/user_param.js';
|
||||
import UserDashboard from './community/user_dashboard.js';
|
||||
import OAuthIdentity from './community/oauth_identity.js';
|
||||
import UserParamType from './type/user_param.js';
|
||||
import UserRightType from './type/user_right.js';
|
||||
import UserRight from './community/user_right.js';
|
||||
@@ -182,6 +183,9 @@ export default function setupAssociations() {
|
||||
User.hasOne(UserDashboard, { foreignKey: 'userId', as: 'dashboard' });
|
||||
UserDashboard.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||
|
||||
User.hasMany(OAuthIdentity, { foreignKey: 'userId', as: 'oauthIdentities' });
|
||||
OAuthIdentity.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||
|
||||
UserParamValue.belongsTo(UserParamType, { foreignKey: 'userParamTypeId', as: 'user_param_value_type' });
|
||||
UserParamType.hasMany(UserParamValue, { foreignKey: 'userParamTypeId', as: 'user_param_type_value' });
|
||||
|
||||
|
||||
54
backend/models/community/oauth_identity.js
Normal file
54
backend/models/community/oauth_identity.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { sequelize } from '../../utils/sequelize.js';
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
const OAuthIdentity = sequelize.define('oauth_identity', {
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'user_id'
|
||||
},
|
||||
provider: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false
|
||||
},
|
||||
issuer: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
subject: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
field: 'created_at',
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
field: 'updated_at',
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'oauth_identity',
|
||||
schema: 'community',
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['provider', 'subject']
|
||||
},
|
||||
{
|
||||
fields: ['user_id']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export default OAuthIdentity;
|
||||
@@ -8,6 +8,7 @@ import WidgetType from './type/widget_type.js';
|
||||
import User from './community/user.js';
|
||||
import UserParam from './community/user_param.js';
|
||||
import UserDashboard from './community/user_dashboard.js';
|
||||
import OAuthIdentity from './community/oauth_identity.js';
|
||||
import Login from './logs/login.js';
|
||||
import UserRight from './community/user_right.js';
|
||||
import InterestType from './type/interest.js';
|
||||
@@ -168,6 +169,7 @@ const models = {
|
||||
User,
|
||||
UserParam,
|
||||
UserDashboard,
|
||||
OAuthIdentity,
|
||||
Login,
|
||||
UserRight,
|
||||
InterestType,
|
||||
|
||||
6712
backend/package-lock.json
generated
6712
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.20.0",
|
||||
"nodemailer": "^8.0.3",
|
||||
"openid-client": "^6.8.4",
|
||||
"pg": "^8.20.0",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"redis": "^5.11.0",
|
||||
|
||||
@@ -9,8 +9,15 @@ router.post('/register', authController.register);
|
||||
router.post('/login', authController.login);
|
||||
router.post('/forgot-password', authController.forgotPassword);
|
||||
router.post('/activate', authController.activateAccount);
|
||||
router.get('/oauth/providers', authController.oauthProviders);
|
||||
router.get('/oauth/:provider/start', authController.oauthStart);
|
||||
router.post('/oauth/exchange', authController.oauthExchange);
|
||||
|
||||
// Geschützte Routen (Authentifizierung erforderlich)
|
||||
router.get('/logout', authController.logout);
|
||||
router.get('/oauth/user/identities', authController.oauthUserIdentities);
|
||||
router.get('/oauth/user/:provider/start', authController.oauthUserStart);
|
||||
router.post('/oauth/user/exchange', authController.oauthUserExchange);
|
||||
router.delete('/oauth/user/:identityId', authController.oauthUserRemove);
|
||||
|
||||
export default router;
|
||||
|
||||
578
backend/services/oauthService.js
Normal file
578
backend/services/oauthService.js
Normal file
@@ -0,0 +1,578 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { Op } from 'sequelize';
|
||||
import * as oidc from 'openid-client';
|
||||
import User from '../models/community/user.js';
|
||||
import UserParam from '../models/community/user_param.js';
|
||||
import UserParamType from '../models/type/user_param.js';
|
||||
import UserParamValue from '../models/type/user_param_value.js';
|
||||
import OAuthIdentity from '../models/community/oauth_identity.js';
|
||||
import Friendship from '../models/community/friendship.js';
|
||||
import { sequelize } from '../utils/sequelize.js';
|
||||
import { redisClient } from '../utils/redis.js';
|
||||
import { setUserSession, deleteUserSession } from '../utils/redis.js';
|
||||
import { notifyUser } from '../utils/socket.js';
|
||||
import { encrypt } from '../utils/encryption.js';
|
||||
|
||||
const saltRounds = 10;
|
||||
const OAUTH_STATE_TTL_SECONDS = 15 * 60;
|
||||
const OAUTH_CALLBACK_PATH = '/auth/oauth/callback';
|
||||
|
||||
const STATIC_PROVIDER_DEFS = [
|
||||
{
|
||||
slug: 'google',
|
||||
label: 'Google',
|
||||
issuer: 'https://accounts.google.com',
|
||||
scope: 'openid email profile',
|
||||
},
|
||||
{
|
||||
slug: 'microsoft',
|
||||
label: 'Microsoft',
|
||||
issuer: 'https://login.microsoftonline.com/common/v2.0',
|
||||
scope: 'openid email profile offline_access',
|
||||
},
|
||||
{
|
||||
slug: 'keycloak',
|
||||
label: 'Keycloak',
|
||||
issuer: process.env.OAUTH_KEYCLOAK_ISSUER || '',
|
||||
scope: 'openid email profile',
|
||||
},
|
||||
{
|
||||
slug: 'ory',
|
||||
label: 'ORY',
|
||||
issuer: process.env.OAUTH_ORY_ISSUER || '',
|
||||
scope: 'openid email profile',
|
||||
},
|
||||
{
|
||||
slug: 'zitadel',
|
||||
label: 'ZITADEL',
|
||||
issuer: process.env.OAUTH_ZITADEL_ISSUER || '',
|
||||
scope: 'openid email profile',
|
||||
},
|
||||
];
|
||||
|
||||
const providerCache = new Map();
|
||||
|
||||
const buildEncryptedEmailCandidates = (email) => {
|
||||
const encrypted = encrypt(email);
|
||||
return [
|
||||
Buffer.from(encrypted, 'utf8'),
|
||||
Buffer.from(encrypted, 'hex')
|
||||
];
|
||||
};
|
||||
|
||||
const getFriends = async (userId) => {
|
||||
const friendships = await Friendship.findAll({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ user1Id: userId },
|
||||
{ user2Id: userId },
|
||||
],
|
||||
accepted: true,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'friendSender',
|
||||
attributes: ['hashedId', 'username'],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: 'friendReceiver',
|
||||
attributes: ['hashedId', 'username'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return friendships.map((friendship) => (
|
||||
friendship.user1Id === userId ? friendship.friendReceiver : friendship.friendSender
|
||||
));
|
||||
};
|
||||
|
||||
const getFrontendCallbackUrl = () => {
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
if (!frontendUrl) {
|
||||
throw new Error('missingfrontendurl');
|
||||
}
|
||||
|
||||
return new URL(OAUTH_CALLBACK_PATH, `${frontendUrl.replace(/\/$/, '')}/`).toString();
|
||||
};
|
||||
|
||||
const normalizeClaims = (claims = {}) => ({
|
||||
subject: claims.sub || claims.subject || '',
|
||||
email: typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : null,
|
||||
verified: claims.email_verified === true || claims.email_verified === 'true',
|
||||
name: typeof claims.name === 'string' ? claims.name.trim() : '',
|
||||
preferredUsername: typeof claims.preferred_username === 'string' ? claims.preferred_username.trim() : '',
|
||||
givenName: typeof claims.given_name === 'string' ? claims.given_name.trim() : '',
|
||||
familyName: typeof claims.family_name === 'string' ? claims.family_name.trim() : '',
|
||||
});
|
||||
|
||||
const sanitizeUsernamePart = (value) => value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 32);
|
||||
|
||||
const createUniqueUsername = async (claims, providerSlug) => {
|
||||
const candidates = [
|
||||
claims.preferredUsername,
|
||||
claims.name,
|
||||
claims.email ? claims.email.split('@')[0] : '',
|
||||
`${providerSlug}-${claims.subject.slice(0, 8)}`,
|
||||
]
|
||||
.map(sanitizeUsernamePart)
|
||||
.filter(Boolean);
|
||||
|
||||
const base = candidates[0] || `${providerSlug}-${claims.subject.slice(0, 8)}`;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt < 50) {
|
||||
const suffix = attempt === 0 ? '' : String(attempt + 1);
|
||||
const username = `${base}${suffix}`.slice(0, 64);
|
||||
const existing = await User.findOne({ where: { username } });
|
||||
if (!existing) {
|
||||
return username;
|
||||
}
|
||||
attempt += 1;
|
||||
}
|
||||
|
||||
return `${providerSlug}-${claims.subject.slice(0, 12)}`.slice(0, 64);
|
||||
};
|
||||
|
||||
const buildFallbackEmail = (providerSlug, subject, email) => {
|
||||
if (email) {
|
||||
return email;
|
||||
}
|
||||
|
||||
const safeSubject = subject.replace(/[^a-zA-Z0-9]+/g, '').slice(0, 32) || crypto.randomBytes(8).toString('hex');
|
||||
return `${providerSlug}-${safeSubject}@oauth.local`;
|
||||
};
|
||||
|
||||
const getProviderDefinitions = () => STATIC_PROVIDER_DEFS.map((provider) => ({
|
||||
...provider,
|
||||
configured: Boolean(provider.issuer) && Boolean(process.env[`OAUTH_${provider.slug.toUpperCase()}_CLIENT_ID`]) && Boolean(process.env[`OAUTH_${provider.slug.toUpperCase()}_CLIENT_SECRET`]),
|
||||
}));
|
||||
|
||||
const getProviderDefinition = (providerSlug) => getProviderDefinitions().find((provider) => provider.slug === providerSlug) || null;
|
||||
|
||||
const getProviderConfiguration = async (providerSlug) => {
|
||||
if (providerCache.has(providerSlug)) {
|
||||
return providerCache.get(providerSlug);
|
||||
}
|
||||
|
||||
const provider = getProviderDefinition(providerSlug);
|
||||
if (!provider || !provider.configured) {
|
||||
throw new Error('providernotconfigured');
|
||||
}
|
||||
|
||||
const clientId = process.env[`OAUTH_${providerSlug.toUpperCase()}_CLIENT_ID`];
|
||||
const clientSecret = process.env[`OAUTH_${providerSlug.toUpperCase()}_CLIENT_SECRET`];
|
||||
const configuration = await oidc.discovery(new URL(provider.issuer), clientId, clientSecret);
|
||||
|
||||
providerCache.set(providerSlug, configuration);
|
||||
return configuration;
|
||||
};
|
||||
|
||||
const storeOAuthState = async (state, payload) => {
|
||||
await redisClient.sendCommand([
|
||||
'SET',
|
||||
`oauth-state:${state}`,
|
||||
JSON.stringify(payload),
|
||||
'EX',
|
||||
String(OAUTH_STATE_TTL_SECONDS)
|
||||
]);
|
||||
};
|
||||
|
||||
const loadOAuthState = async (state) => {
|
||||
const raw = await redisClient.sendCommand(['GET', `oauth-state:${state}`]);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await redisClient.sendCommand(['DEL', `oauth-state:${state}`]);
|
||||
return JSON.parse(raw);
|
||||
};
|
||||
|
||||
const findUserByEmail = async (email) => {
|
||||
const encryptedEmailCandidates = buildEncryptedEmailCandidates(email);
|
||||
const query = `
|
||||
SELECT id FROM community."user"
|
||||
WHERE email = ANY(:encryptedEmails)
|
||||
`;
|
||||
const rows = await sequelize.query(query, {
|
||||
replacements: { encryptedEmails: encryptedEmailCandidates },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
});
|
||||
|
||||
if (!rows.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return User.findByPk(rows[0].id);
|
||||
};
|
||||
|
||||
const loadUserParams = async (userId) => {
|
||||
const params = await UserParam.findAll({
|
||||
where: { userId },
|
||||
include: {
|
||||
model: UserParamType,
|
||||
as: 'paramType',
|
||||
where: {
|
||||
description: {
|
||||
[Op.in]: ['birthdate', 'gender', 'language']
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const mappedParams = params.map((param) => ({
|
||||
name: param.paramType.description,
|
||||
value: param.value
|
||||
}));
|
||||
|
||||
const languageEntry = mappedParams.find((param) => param.name === 'language');
|
||||
if (languageEntry?.value) {
|
||||
const uiLocaleCodes = ['de', 'en', 'ceb', 'es', 'fr'];
|
||||
if (!uiLocaleCodes.includes(languageEntry.value)) {
|
||||
const numericValue = Number.parseInt(languageEntry.value, 10);
|
||||
const lookupId = Number.isNaN(numericValue) ? languageEntry.value : numericValue;
|
||||
const mappedValue = await UserParamValue.findOne({ where: { id: lookupId } });
|
||||
if (mappedValue?.value && uiLocaleCodes.includes(mappedValue.value)) {
|
||||
languageEntry.value = mappedValue.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mappedParams;
|
||||
};
|
||||
|
||||
const buildLoginPayload = async (user) => ({
|
||||
id: user.hashedId,
|
||||
username: user.username,
|
||||
active: user.active,
|
||||
param: await loadUserParams(user.id),
|
||||
authCode: user.authCode,
|
||||
});
|
||||
|
||||
const setOAuthSession = async (user) => {
|
||||
const authCode = crypto.randomBytes(20).toString('hex');
|
||||
user.authCode = authCode;
|
||||
await user.save();
|
||||
|
||||
const friends = await getFriends(user.id);
|
||||
for (const friend of friends) {
|
||||
await notifyUser(friend.hashedId, 'friendloginchanged', {
|
||||
userId: user.hashedId,
|
||||
status: 'online',
|
||||
});
|
||||
}
|
||||
|
||||
await setUserSession(user.id, {
|
||||
id: user.hashedId,
|
||||
username: user.username,
|
||||
active: user.active,
|
||||
authCode,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return buildLoginPayload(user);
|
||||
};
|
||||
|
||||
const createUserFromClaims = async (providerSlug, providerConfiguration, claims) => {
|
||||
const email = buildFallbackEmail(providerSlug, claims.subject, claims.email);
|
||||
const username = await createUniqueUsername({ ...claims, email }, providerSlug);
|
||||
const hashedPassword = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), saltRounds);
|
||||
|
||||
const user = await User.create({
|
||||
email,
|
||||
username,
|
||||
password: hashedPassword,
|
||||
active: true,
|
||||
searchable: true,
|
||||
registrationDate: new Date(),
|
||||
});
|
||||
|
||||
await OAuthIdentity.create({
|
||||
userId: user.id,
|
||||
provider: providerSlug,
|
||||
issuer: providerConfiguration.serverMetadata().issuer,
|
||||
subject: claims.subject,
|
||||
email,
|
||||
});
|
||||
|
||||
await user.reload();
|
||||
return user;
|
||||
};
|
||||
|
||||
const linkIdentityToUser = async (user, providerSlug, providerConfiguration, claims) => {
|
||||
const existing = await OAuthIdentity.findOne({
|
||||
where: {
|
||||
provider: providerSlug,
|
||||
subject: claims.subject,
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.userId !== user.id) {
|
||||
throw new Error('oauthidentityconflict');
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
return OAuthIdentity.create({
|
||||
userId: user.id,
|
||||
provider: providerSlug,
|
||||
issuer: providerConfiguration.serverMetadata().issuer,
|
||||
subject: claims.subject,
|
||||
email: claims.email || user.email,
|
||||
});
|
||||
};
|
||||
|
||||
export const getOAuthProviders = async () => getProviderDefinitions().filter((provider) => provider.configured);
|
||||
|
||||
export const startOAuthLogin = async ({ providerSlug }) => {
|
||||
const provider = getProviderDefinition(providerSlug);
|
||||
if (!provider || !provider.configured) {
|
||||
throw new Error('providernotconfigured');
|
||||
}
|
||||
|
||||
const configuration = await getProviderConfiguration(providerSlug);
|
||||
const codeVerifier = oidc.randomPKCECodeVerifier();
|
||||
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
||||
const state = oidc.randomState();
|
||||
const redirectUri = getFrontendCallbackUrl();
|
||||
|
||||
await storeOAuthState(state, {
|
||||
providerSlug,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
return oidc.buildAuthorizationUrl(configuration, {
|
||||
redirect_uri: redirectUri,
|
||||
scope: provider.scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
prompt: 'select_account',
|
||||
});
|
||||
};
|
||||
|
||||
export const exchangeOAuthLogin = async ({ code, state }) => {
|
||||
if (!code || !state) {
|
||||
throw new Error('oauthcodemissing');
|
||||
}
|
||||
|
||||
const stateData = await loadOAuthState(state);
|
||||
if (!stateData?.providerSlug || !stateData.codeVerifier || !stateData.redirectUri) {
|
||||
throw new Error('oauthstatemissing');
|
||||
}
|
||||
|
||||
const provider = getProviderDefinition(stateData.providerSlug);
|
||||
if (!provider || !provider.configured) {
|
||||
throw new Error('providernotconfigured');
|
||||
}
|
||||
|
||||
const configuration = await getProviderConfiguration(stateData.providerSlug);
|
||||
const callbackUrl = new URL(stateData.redirectUri);
|
||||
callbackUrl.searchParams.set('code', code);
|
||||
callbackUrl.searchParams.set('state', state);
|
||||
|
||||
const tokens = await oidc.authorizationCodeGrant(configuration, callbackUrl, {
|
||||
pkceCodeVerifier: stateData.codeVerifier,
|
||||
expectedState: state,
|
||||
});
|
||||
|
||||
let claims = typeof tokens.claims === 'function' ? tokens.claims() : null;
|
||||
if (!claims && tokens.access_token) {
|
||||
claims = await oidc.fetchUserInfo(configuration, tokens.access_token, oidc.skipSubjectCheck);
|
||||
}
|
||||
|
||||
const normalizedClaims = normalizeClaims(claims || {});
|
||||
if (!normalizedClaims.subject) {
|
||||
throw new Error('oauthsubjectmissing');
|
||||
}
|
||||
|
||||
const identity = await OAuthIdentity.findOne({
|
||||
where: {
|
||||
provider: provider.slug,
|
||||
subject: normalizedClaims.subject,
|
||||
}
|
||||
});
|
||||
|
||||
let user = identity ? await User.findByPk(identity.userId) : null;
|
||||
|
||||
if (!user && normalizedClaims.email) {
|
||||
user = await findUserByEmail(normalizedClaims.email);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
user = await createUserFromClaims(provider.slug, configuration, normalizedClaims);
|
||||
} else {
|
||||
await linkIdentityToUser(user, provider.slug, configuration, normalizedClaims);
|
||||
if (!user.active) {
|
||||
user.active = true;
|
||||
await user.save();
|
||||
}
|
||||
}
|
||||
|
||||
if (!user.active) {
|
||||
user.active = true;
|
||||
await user.save();
|
||||
}
|
||||
|
||||
return setOAuthSession(user);
|
||||
};
|
||||
|
||||
export const logoutOAuthUser = async (hashedUserId) => {
|
||||
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const friends = await getFriends(user.id);
|
||||
for (const friend of friends) {
|
||||
await notifyUser(friend.hashedId, 'friendloginchanged', {
|
||||
userId: user.hashedId,
|
||||
status: 'online',
|
||||
});
|
||||
}
|
||||
|
||||
await deleteUserSession(user.id);
|
||||
};
|
||||
|
||||
export const getUserOAuthIdentities = async (userId) => {
|
||||
const identities = await OAuthIdentity.findAll({
|
||||
where: { userId },
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
return identities.map(identity => ({
|
||||
id: identity.id,
|
||||
provider: identity.provider,
|
||||
email: identity.email,
|
||||
createdAt: identity.createdAt,
|
||||
displayName: `${identity.provider} (${identity.email || identity.subject.slice(0, 10)})`
|
||||
}));
|
||||
};
|
||||
|
||||
export const startOAuthLoginForUser = async ({ userId, providerSlug }) => {
|
||||
const provider = getProviderDefinition(providerSlug);
|
||||
if (!provider || !provider.configured) {
|
||||
throw new Error('providernotconfigured');
|
||||
}
|
||||
|
||||
const configuration = await getProviderConfiguration(providerSlug);
|
||||
const codeVerifier = oidc.randomPKCECodeVerifier();
|
||||
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
||||
const state = oidc.randomState();
|
||||
const callbackPath = '/auth/oauth/user/callback';
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
if (!frontendUrl) {
|
||||
throw new Error('missingfrontendurl');
|
||||
}
|
||||
const redirectUri = new URL(callbackPath, `${frontendUrl.replace(/\/$/, '')}/`).toString();
|
||||
|
||||
await storeOAuthState(state, {
|
||||
userId,
|
||||
providerSlug,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
return oidc.buildAuthorizationUrl(configuration, {
|
||||
redirect_uri: redirectUri,
|
||||
scope: provider.scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
prompt: 'select_account',
|
||||
});
|
||||
};
|
||||
|
||||
export const exchangeOAuthLoginForUser = async ({ userId, code, state }) => {
|
||||
if (!code || !state) {
|
||||
throw new Error('oauthcodemissing');
|
||||
}
|
||||
|
||||
const stateData = await loadOAuthState(state);
|
||||
if (!stateData?.userId || !stateData.providerSlug || !stateData.codeVerifier || !stateData.redirectUri) {
|
||||
throw new Error('oauthstatemissing');
|
||||
}
|
||||
|
||||
if (stateData.userId !== userId) {
|
||||
throw new Error('oauthuseridmismatch');
|
||||
}
|
||||
|
||||
const provider = getProviderDefinition(stateData.providerSlug);
|
||||
if (!provider || !provider.configured) {
|
||||
throw new Error('providernotconfigured');
|
||||
}
|
||||
|
||||
const configuration = await getProviderConfiguration(stateData.providerSlug);
|
||||
const callbackUrl = new URL(stateData.redirectUri);
|
||||
callbackUrl.searchParams.set('code', code);
|
||||
callbackUrl.searchParams.set('state', state);
|
||||
|
||||
const tokens = await oidc.authorizationCodeGrant(configuration, callbackUrl, {
|
||||
pkceCodeVerifier: stateData.codeVerifier,
|
||||
expectedState: state,
|
||||
});
|
||||
|
||||
let claims = typeof tokens.claims === 'function' ? tokens.claims() : null;
|
||||
if (!claims && tokens.access_token) {
|
||||
claims = await oidc.fetchUserInfo(configuration, tokens.access_token, oidc.skipSubjectCheck);
|
||||
}
|
||||
|
||||
const normalizedClaims = normalizeClaims(claims || {});
|
||||
if (!normalizedClaims.subject) {
|
||||
throw new Error('oauthsubjectmissing');
|
||||
}
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
throw new Error('usernotiound');
|
||||
}
|
||||
|
||||
const existingIdentity = await OAuthIdentity.findOne({
|
||||
where: {
|
||||
provider: provider.slug,
|
||||
subject: normalizedClaims.subject,
|
||||
}
|
||||
});
|
||||
|
||||
if (existingIdentity && existingIdentity.userId !== userId) {
|
||||
throw new Error('oauthidentityalreadylinked');
|
||||
}
|
||||
|
||||
await linkIdentityToUser(user, provider.slug, configuration, normalizedClaims);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
identity: {
|
||||
id: existingIdentity?.id,
|
||||
provider: provider.slug,
|
||||
email: normalizedClaims.email,
|
||||
displayName: `${provider.label} (${normalizedClaims.email || normalizedClaims.subject.slice(0, 10)})`
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const removeOAuthIdentity = async ({ userId, identityId }) => {
|
||||
const identity = await OAuthIdentity.findByPk(identityId);
|
||||
if (!identity) {
|
||||
throw new Error('identitynotfound');
|
||||
}
|
||||
|
||||
if (identity.userId !== userId) {
|
||||
throw new Error('forbidden');
|
||||
}
|
||||
|
||||
await identity.destroy();
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
Reference in New Issue
Block a user