diff --git a/.env.example b/.env.example index 8149c31..dd1ec2f 100644 --- a/.env.example +++ b/.env.example @@ -90,3 +90,20 @@ REFERRAL_RATE_LIMIT_MAX_ATTEMPTS=10 REFERRAL_ENABLE_BOT_DETECTION=true # Enable VPN/Proxy detection (requires external service) REFERRAL_ENABLE_VPN_DETECTION=false + + +# OAuth Configuration +AUTH_OAUTH_ENABLED=true +OAUTH_REDIRECT_URI=http://localhost:3000/auth/oauth/callback + +# Google OAuth +OAUTH_GOOGLE_CLIENT_ID=your_google_client_id +OAUTH_GOOGLE_CLIENT_SECRET=your_google_client_secret + +# GitHub OAuth +OAUTH_GITHUB_CLIENT_ID=your_github_client_id +OAUTH_GITHUB_CLIENT_SECRET=your_github_client_secret + +# Twitter/X OAuth +OAUTH_TWITTER_CLIENT_ID=your_twitter_client_id +OAUTH_TWITTER_CLIENT_SECRET=your_twitter_client_secret diff --git a/src/core/auth/auth.module.ts b/src/core/auth/auth.module.ts index 39c83c5..d6715cd 100644 --- a/src/core/auth/auth.module.ts +++ b/src/core/auth/auth.module.ts @@ -1,3 +1,6 @@ +import { SocialAccount } from "./entities/social-account.entity"; +import { OAuthController } from "./oauth.controller"; +import { AuditLogService } from "src/infrastructure/audit/audit-log.service"; import { Module, OnModuleInit } from "@nestjs/common"; import { JwtModule } from "@nestjs/jwt"; import { PassportModule } from "@nestjs/passport"; @@ -74,10 +77,11 @@ import { RefreshToken, TwoFactorAuth } from "./entities/auth.entity"; Wallet, RefreshToken, TwoFactorAuth, + SocialAccount, ]), AuditModule, ], - controllers: [AuthController], + controllers: [AuthController, OAuthController], providers: [ // Legacy services (for backward compatibility) AuthService, @@ -102,6 +106,7 @@ import { RefreshToken, TwoFactorAuth } from "./entities/auth.entity"; ApiKeyStrategy, StrategyAuthGuard, AdminTwoFactorGuard, + AuditLogService, ], exports: [ // Legacy exports diff --git a/src/core/auth/auth.service.spec.ts b/src/core/auth/auth.service.spec.ts index d560206..448b5fb 100644 --- a/src/core/auth/auth.service.spec.ts +++ b/src/core/auth/auth.service.spec.ts @@ -43,6 +43,7 @@ describe("AuthService", () => { referredById: null, referredBy: null, referrals: [], + socialAccounts: [], }; const mockJwtService = { diff --git a/src/core/auth/dto/oauth.dto.ts b/src/core/auth/dto/oauth.dto.ts new file mode 100644 index 0000000..a30b5fb --- /dev/null +++ b/src/core/auth/dto/oauth.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, IsNotEmpty, IsOptional } from "class-validator"; + +export class OAuthCallbackDto { + @ApiProperty({ description: "Authorization code from OAuth provider" }) + @IsString() + @IsNotEmpty() + code: string; + + @ApiProperty({ description: "State parameter for CSRF protection", required: false }) + @IsString() + @IsOptional() + state?: string; +} + +export class OAuthLinkDto { + @ApiProperty({ description: "Authorization code from OAuth provider" }) + @IsString() + @IsNotEmpty() + code: string; + + @ApiProperty({ description: "State parameter", required: false }) + @IsString() + @IsOptional() + state?: string; +} diff --git a/src/core/auth/entities/social-account.entity.ts b/src/core/auth/entities/social-account.entity.ts new file mode 100644 index 0000000..9efa699 --- /dev/null +++ b/src/core/auth/entities/social-account.entity.ts @@ -0,0 +1,57 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, + Unique, +} from "typeorm"; +import { User } from "src/core/user/entities/user.entity"; + +export enum SocialProvider { + GOOGLE = "google", + GITHUB = "github", + TWITTER = "twitter", +} + +@Entity("social_accounts") +@Unique(["provider", "providerUserId"]) +export class SocialAccount { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ type: "uuid" }) + @Index() + userId: string; + + @ManyToOne(() => User, (user) => user.socialAccounts, { onDelete: "CASCADE" }) + @JoinColumn({ name: "userId" }) + user: User; + + @Column({ type: "varchar" }) + provider: SocialProvider; + + @Column() + providerUserId: string; + + @Column({ nullable: true }) + email: string | null; + + @Column({ nullable: true }) + displayName: string | null; + + @Column({ nullable: true }) + avatarUrl: string | null; + + @Column({ default: false }) + emailVerified: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/core/auth/oauth.controller.ts b/src/core/auth/oauth.controller.ts new file mode 100644 index 0000000..b348fbc --- /dev/null +++ b/src/core/auth/oauth.controller.ts @@ -0,0 +1,99 @@ +import { + Controller, + Post, + Get, + Delete, + Param, + Body, + UseGuards, + Request, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, +} from "@nestjs/swagger"; +import { OAuthStrategy } from "./strategies/oauth/oauth.strategy"; +import { JwtAuthGuard } from "./jwt.guard"; +import { Public } from "src/common/decorators/public.decorator"; +import { OAuthCallbackDto, OAuthLinkDto } from "./dto/oauth.dto"; +import { v4 as uuidv4 } from "uuid"; + +@ApiTags("OAuth / Social Auth") +@Controller("auth/oauth") +export class OAuthController { + constructor(private readonly oauthStrategy: OAuthStrategy) {} + + @Public() + @Get(":provider") + @ApiOperation({ summary: "Get OAuth authorization URL for a provider" }) + @ApiParam({ name: "provider", enum: ["google", "github", "twitter"] }) + @ApiResponse({ status: 200, description: "Returns the authorization URL" }) + getAuthorizationUrl(@Param("provider") provider: string) { + const state = uuidv4(); + const url = this.oauthStrategy.getAuthorizationUrl(provider, state); + return { url, state }; + } + + @Public() + @Post(":provider/callback") + @ApiOperation({ summary: "Handle OAuth callback and issue JWT" }) + @ApiParam({ name: "provider", enum: ["google", "github", "twitter"] }) + @ApiResponse({ status: 200, description: "Returns JWT token and user info" }) + @ApiResponse({ status: 401, description: "OAuth authentication failed" }) + async handleCallback( + @Param("provider") provider: string, + @Body() dto: OAuthCallbackDto, + ) { + return this.oauthStrategy.authenticate({ provider, code: dto.code, state: dto.state }); + } + + @Post(":provider/link") + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: "Link a social provider to your existing account" }) + @ApiParam({ name: "provider", enum: ["google", "github", "twitter"] }) + @ApiResponse({ status: 200, description: "Social account linked successfully" }) + async linkProvider( + @Param("provider") provider: string, + @Body() dto: OAuthLinkDto, + @Request() req, + ) { + const userId = req.user.sub || req.user.id; + return this.oauthStrategy.linkProvider(userId, provider, dto.code); + } + + @Delete(":provider/unlink") + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: "Unlink a social provider from your account" }) + @ApiParam({ name: "provider", enum: ["google", "github", "twitter"] }) + @ApiResponse({ status: 200, description: "Social account unlinked successfully" }) + async unlinkProvider( + @Param("provider") provider: string, + @Request() req, + ) { + const userId = req.user.sub || req.user.id; + return this.oauthStrategy.unlinkProvider(userId, provider); + } + + @Get("providers/linked") + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: "Get all linked social providers for current user" }) + @ApiResponse({ status: 200, description: "List of linked providers" }) + async getLinkedProviders(@Request() req) { + const userId = req.user.sub || req.user.id; + return this.oauthStrategy.getLinkedProviders(userId); + } + + @Public() + @Get("providers/available") + @ApiOperation({ summary: "Get list of available OAuth providers" }) + @ApiResponse({ status: 200, description: "List of enabled providers" }) + getAvailableProviders() { + return { providers: this.oauthStrategy.getAvailableProviders() }; + } +} diff --git a/src/core/auth/strategies/oauth/oauth.strategy.ts b/src/core/auth/strategies/oauth/oauth.strategy.ts index b0285db..90fe537 100644 --- a/src/core/auth/strategies/oauth/oauth.strategy.ts +++ b/src/core/auth/strategies/oauth/oauth.strategy.ts @@ -15,13 +15,9 @@ import { OAuthCredentials, } from "../interfaces/auth-strategy.interface"; import { User } from "src/core/user/entities/user.entity"; -import { - resolveRateLimitTierFromRole, -} from "src/config/quota.config"; +import { SocialAccount, SocialProvider } from "../../entities/social-account.entity"; +import { AuditLogService } from "src/infrastructure/audit/audit-log.service"; -/** - * OAuth provider configuration - */ interface OAuthProviderConfig { clientId: string; clientSecret: string; @@ -31,20 +27,13 @@ interface OAuthProviderConfig { scopes: string[]; } -/** - * OAuth user info from provider - */ interface OAuthUserInfo { id: string; - email: string; + email: string | null; name?: string; picture?: string; } -/** - * OAuth authentication strategy - * Supports multiple OAuth providers (Google, GitHub, etc.) - */ @Injectable() export class OAuthStrategy implements AuthStrategy { readonly name = "oauth"; @@ -56,21 +45,18 @@ export class OAuthStrategy implements AuthStrategy { private readonly jwtService: JwtService, @InjectRepository(User) private readonly userRepository: Repository, + @InjectRepository(SocialAccount) + private readonly socialAccountRepository: Repository, + private readonly auditLogService: AuditLogService, ) { this.initializeProviders(); } - /** - * Initialize OAuth providers from configuration - */ private initializeProviders(): void { - // Google OAuth if (this.configService.get("OAUTH_GOOGLE_CLIENT_ID")) { this.providers.set("google", { clientId: this.configService.get("OAUTH_GOOGLE_CLIENT_ID")!, - clientSecret: this.configService.get( - "OAUTH_GOOGLE_CLIENT_SECRET", - )!, + clientSecret: this.configService.get("OAUTH_GOOGLE_CLIENT_SECRET")!, authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", tokenUrl: "https://oauth2.googleapis.com/token", userInfoUrl: "https://www.googleapis.com/oauth2/v2/userinfo", @@ -78,13 +64,10 @@ export class OAuthStrategy implements AuthStrategy { }); } - // GitHub OAuth if (this.configService.get("OAUTH_GITHUB_CLIENT_ID")) { this.providers.set("github", { clientId: this.configService.get("OAUTH_GITHUB_CLIENT_ID")!, - clientSecret: this.configService.get( - "OAUTH_GITHUB_CLIENT_SECRET", - )!, + clientSecret: this.configService.get("OAUTH_GITHUB_CLIENT_SECRET")!, authorizationUrl: "https://github.com/login/oauth/authorize", tokenUrl: "https://github.com/login/oauth/access_token", userInfoUrl: "https://api.github.com/user", @@ -92,12 +75,20 @@ export class OAuthStrategy implements AuthStrategy { }); } + if (this.configService.get("OAUTH_TWITTER_CLIENT_ID")) { + this.providers.set("twitter", { + clientId: this.configService.get("OAUTH_TWITTER_CLIENT_ID")!, + clientSecret: this.configService.get("OAUTH_TWITTER_CLIENT_SECRET")!, + authorizationUrl: "https://twitter.com/i/oauth2/authorize", + tokenUrl: "https://api.twitter.com/2/oauth2/token", + userInfoUrl: "https://api.twitter.com/2/users/me?user.fields=profile_image_url", + scopes: ["tweet.read", "users.read"], + }); + } + this.logger.log(`Initialized ${this.providers.size} OAuth providers`); } - /** - * Check if OAuth strategy is enabled - */ get isEnabled(): boolean { return ( this.configService.get("AUTH_OAUTH_ENABLED", false) && @@ -105,25 +96,37 @@ export class OAuthStrategy implements AuthStrategy { ); } - /** - * Get available OAuth providers - */ getAvailableProviders(): string[] { return Array.from(this.providers.keys()); } - /** - * Authenticate using OAuth - * @param credentials - OAuth credentials containing provider and code - * @returns Authentication result with JWT token - */ + getAuthorizationUrl(provider: string, state: string): string { + const config = this.providers.get(provider); + if (!config) { + throw new BadRequestException(`Unsupported OAuth provider: ${provider}`); + } + + const redirectUri = this.configService.get( + "OAUTH_REDIRECT_URI", + "http://localhost:3000/auth/oauth/callback", + ); + + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: `${redirectUri}/${provider}`, + response_type: "code", + scope: config.scopes.join(" "), + state, + }); + + return `${config.authorizationUrl}?${params.toString()}`; + } + async authenticate(credentials: unknown): Promise { const { provider, code } = credentials as OAuthCredentials; if (!provider || !code) { - throw new BadRequestException( - "Provider and authorization code are required", - ); + throw new BadRequestException("Provider and authorization code are required"); } const providerConfig = this.providers.get(provider); @@ -131,158 +134,250 @@ export class OAuthStrategy implements AuthStrategy { throw new BadRequestException(`Unsupported OAuth provider: ${provider}`); } - // Exchange code for access token - const accessToken = await this.exchangeCodeForToken(providerConfig, code); + try { + const accessToken = await this.exchangeCodeForToken(providerConfig, provider, code); + const userInfo = await this.getUserInfo(providerConfig, provider, accessToken); + const user = await this.findOrCreateUser(userInfo, provider); - // Get user info from provider - const userInfo = await this.getUserInfo(providerConfig, accessToken); + await this.auditLogService.recordVerification({ + action: "oauth_login", + userId: user.id, + provider, + email: userInfo.email, + timestamp: new Date().toISOString(), + }); - // Find or create user - const user = await this.findOrCreateUser(userInfo, provider); + return this.issueToken(user, provider); + } catch (error) { + await this.auditLogService.recordVerification({ + action: "oauth_login_failed", + provider, + error: (error as Error).message, + timestamp: new Date().toISOString(), + }); + throw error; + } + } - // Generate JWT token - const payload: AuthPayload = { - sub: user.id, - email: user.email, - username: user.username, - role: user.role || "user", - tier: resolveRateLimitTierFromRole(user.role), - iat: Math.floor(Date.now() / 1000), - type: "oauth", - }; + async linkProvider(userId: string, provider: string, code: string): Promise<{ message: string }> { + const providerConfig = this.providers.get(provider); + if (!providerConfig) { + throw new BadRequestException(`Unsupported OAuth provider: ${provider}`); + } - const token = this.jwtService.sign(payload); + const accessToken = await this.exchangeCodeForToken(providerConfig, provider, code); + const userInfo = await this.getUserInfo(providerConfig, provider, accessToken); - this.logger.log( - `User authenticated via OAuth (${provider}): ${user.email}`, - ); + const existingSocial = await this.socialAccountRepository.findOne({ + where: { provider: provider as SocialProvider, providerUserId: userInfo.id }, + }); - return { - token, - user: { - id: user.id, - email: user.email, - username: user.username, - role: user.role || "user", - tier: resolveRateLimitTierFromRole(user.role), - type: "oauth", - }, - }; + if (existingSocial && existingSocial.userId !== userId) { + throw new BadRequestException( + `This ${provider} account is already linked to another user`, + ); + } + + if (!existingSocial) { + const social = this.socialAccountRepository.create({ + userId, + provider: provider as SocialProvider, + providerUserId: userInfo.id, + email: userInfo.email, + displayName: userInfo.name, + avatarUrl: userInfo.picture, + emailVerified: !!userInfo.email, + }); + await this.socialAccountRepository.save(social); + } + + await this.auditLogService.recordVerification({ + action: "oauth_account_linked", + userId, + provider, + timestamp: new Date().toISOString(), + }); + + return { message: `${provider} account linked successfully` }; + } + + async unlinkProvider(userId: string, provider: string): Promise<{ message: string }> { + const social = await this.socialAccountRepository.findOne({ + where: { userId, provider: provider as SocialProvider }, + }); + + if (!social) { + throw new BadRequestException(`No ${provider} account linked to this user`); + } + + await this.socialAccountRepository.remove(social); + + await this.auditLogService.recordVerification({ + action: "oauth_account_unlinked", + userId, + provider, + timestamp: new Date().toISOString(), + }); + + return { message: `${provider} account unlinked successfully` }; + } + + async getLinkedProviders(userId: string): Promise<{ provider: string; email: string | null; linkedAt: Date }[]> { + const accounts = await this.socialAccountRepository.find({ where: { userId } }); + return accounts.map((a) => ({ + provider: a.provider, + email: a.email, + linkedAt: a.createdAt, + })); } - /** - * Exchange authorization code for access token - */ private async exchangeCodeForToken( config: OAuthProviderConfig, + provider: string, code: string, ): Promise { - try { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - client_id: config.clientId, - client_secret: config.clientSecret, - code, - grant_type: "authorization_code", - redirect_uri: this.configService.get( - "OAUTH_REDIRECT_URI", - "http://localhost:3000/auth/oauth/callback", - ), - }), - }); + const redirectUri = this.configService.get( + "OAUTH_REDIRECT_URI", + "http://localhost:3000/auth/oauth/callback", + ); - if (!response.ok) { - throw new UnauthorizedException( - "Failed to exchange OAuth code for token", - ); - } + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }; - const data = await response.json(); - return data.access_token; - } catch (error) { - this.logger.error("OAuth token exchange failed", error); - throw new UnauthorizedException("OAuth authentication failed"); + if (provider === "twitter") { + const credentials = Buffer.from( + `${config.clientId}:${config.clientSecret}`, + ).toString("base64"); + headers["Authorization"] = `Basic ${credentials}`; } + + const response = await fetch(config.tokenUrl, { + method: "POST", + headers, + body: new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + grant_type: "authorization_code", + redirect_uri: `${redirectUri}/${provider}`, + }), + }); + + if (!response.ok) { + this.logger.error(`Token exchange failed for ${provider}: ${response.status}`); + throw new UnauthorizedException("Failed to exchange OAuth code for token"); + } + + const data = await response.json(); + return data.access_token; } - /** - * Get user info from OAuth provider - */ private async getUserInfo( config: OAuthProviderConfig, + provider: string, accessToken: string, ): Promise { - try { - const response = await fetch(config.userInfoUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); + const response = await fetch(config.userInfoUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); - if (!response.ok) { - throw new UnauthorizedException( - "Failed to fetch user info from OAuth provider", - ); - } + if (!response.ok) { + throw new UnauthorizedException("Failed to fetch user info from OAuth provider"); + } + + const data = await response.json(); - const data = await response.json(); + if (provider === "twitter") { + const twitterUser = data.data || data; return { - id: data.id || data.sub, - email: data.email, - name: data.name || data.login, - picture: data.picture || data.avatar_url, + id: twitterUser.id, + email: null, + name: twitterUser.name, + picture: twitterUser.profile_image_url, }; - } catch (error) { - this.logger.error("OAuth user info fetch failed", error); - throw new UnauthorizedException("OAuth authentication failed"); } + + return { + id: String(data.id || data.sub), + email: data.email || null, + name: data.name || data.login, + picture: data.picture || data.avatar_url, + }; } - /** - * Find existing user or create new one - */ - private async findOrCreateUser( - userInfo: OAuthUserInfo, - provider: string, - ): Promise { - // Try to find user by email - let user = await this.userRepository.findOne({ - where: { email: userInfo.email }, + private async findOrCreateUser(userInfo: OAuthUserInfo, provider: string): Promise { + const existingSocial = await this.socialAccountRepository.findOne({ + where: { provider: provider as SocialProvider, providerUserId: userInfo.id }, + relations: ["user"], }); + if (existingSocial) { + return existingSocial.user; + } + + let user: User | null = null; + if (userInfo.email) { + user = await this.userRepository.findOne({ where: { email: userInfo.email } }); + } + if (!user) { - // Create new user user = this.userRepository.create({ email: userInfo.email, - username: userInfo.name || `oauth_${userInfo.id}`, + username: userInfo.name + ? `${userInfo.name.replace(/\s+/g, "_").toLowerCase()}_${Math.random().toString(36).slice(2, 6)}` + : `${provider}_${userInfo.id}`, walletAddress: `oauth_${provider}_${userInfo.id}`, - emailVerified: true, + emailVerified: !!userInfo.email, }); await this.userRepository.save(user); - this.logger.log(`Created new user from OAuth: ${userInfo.email}`); + this.logger.log(`Created new user from OAuth (${provider}): ${userInfo.email}`); } + const social = this.socialAccountRepository.create({ + userId: user.id, + provider: provider as SocialProvider, + providerUserId: userInfo.id, + email: userInfo.email, + displayName: userInfo.name, + avatarUrl: userInfo.picture, + emailVerified: !!userInfo.email, + }); + await this.socialAccountRepository.save(social); + return user; } - /** - * Validate a JWT token - * @param token - The JWT token to validate - * @returns The decoded payload or null if invalid - */ + private issueToken(user: User, provider: string): AuthResult { + const payload: AuthPayload = { + sub: user.id, + email: user.email ?? undefined, + username: user.username ?? undefined, + role: user.role || "user", + iat: Math.floor(Date.now() / 1000), + type: "oauth", + }; + + const token = this.jwtService.sign(payload); + + return { + token, + user: { + id: user.id, + email: user.email ?? undefined, + username: user.username ?? undefined, + role: user.role || "user", + type: "oauth", + }, + }; + } + async validateToken(token: string): Promise { try { return this.jwtService.verify(token) as AuthPayload; - } catch (error) { - this.logger.warn("Token validation failed", error); + } catch { return null; } } } - - diff --git a/src/core/auth/wallet-auth.service.spec.ts b/src/core/auth/wallet-auth.service.spec.ts index 22c4d1d..d7ab0e7 100644 --- a/src/core/auth/wallet-auth.service.spec.ts +++ b/src/core/auth/wallet-auth.service.spec.ts @@ -95,6 +95,7 @@ describe("WalletAuthService", () => { referredById: null, referredBy: null, referrals: [], + socialAccounts: [], }; const mockChallengeService = { diff --git a/src/core/user/entities/user.entity.ts b/src/core/user/entities/user.entity.ts index a0d57d4..d09e4d1 100644 --- a/src/core/user/entities/user.entity.ts +++ b/src/core/user/entities/user.entity.ts @@ -1,3 +1,4 @@ +import { SocialAccount } from "src/core/auth/entities/social-account.entity"; import { Entity, PrimaryGeneratedColumn, @@ -98,7 +99,7 @@ export class User { @OneToMany(() => User, (user) => user.referredBy) referrals: User[]; -} - - + @OneToMany(() => SocialAccount, (social) => social.user) + socialAccounts: SocialAccount[]; +}