Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,9 @@ MONGODB_URI=mongodb+srv://<username>:<pass>@cluster0.thi5oil.mongodb.net/<databa
JWT_SECRET=123456789
JWT_REFRESH_SECRET=987654321
FRONTEND_URL=http://localhost:5173
NODE_ENV=development
NODE_ENV=development

# Wikimedia OAuth 2.0 Credentials (Get these from https://meta.wikimedia.org/wiki/Special:OAuthConsumerRegistration)
WIKIMEDIA_CLIENT_ID=your_client_id_here
WIKIMEDIA_CLIENT_SECRET=your_client_secret_here
WIKIMEDIA_CALLBACK_URL=http://localhost:5000/api/auth/wikimedia/callback
91 changes: 90 additions & 1 deletion backend/src/controllers/authController.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import User from '../models/User.js';
import { sendTokenResponse, verifyRefreshToken, generateAccessToken } from '../utils/jwt.js';
import { sendTokenResponse, verifyRefreshToken, generateAccessToken, generateRefreshToken } from '../utils/jwt.js';
import AppError from '../utils/AppError.js';
import { ErrorCodes } from '../utils/errorCodes.js';

Expand Down Expand Up @@ -187,3 +187,92 @@ export const changePassword = async (req, res, next) => {
}
};

export const wikimediaLogin = async (req, res, next) => {
try {
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.WIKIMEDIA_CLIENT_ID,
redirect_uri: process.env.WIKIMEDIA_CALLBACK_URL
});
Comment on lines +192 to +196

res.redirect(`https://meta.wikimedia.org/w/rest.php/oauth2/authorize?${params.toString()}`);
} catch (error) {
next(error);
}
};

export const wikimediaCallback = async (req, res, next) => {
try {
const { code, error: oauthError } = req.query;

if (oauthError || !code) {
return res.redirect(`${process.env.FRONTEND_URL}/auth?error=${oauthError || 'No code provided'}`);
}
Comment on lines +208 to +210

const userAgent = process.env.WIKIMEDIA_USER_AGENT || 'WikiSourceVerifier/1.0 (Open Source Application; admin@localhost)';

const tokenResponse = await fetch('https://meta.wikimedia.org/w/rest.php/oauth2/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': userAgent
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: process.env.WIKIMEDIA_CLIENT_ID,
client_secret: process.env.WIKIMEDIA_CLIENT_SECRET,
redirect_uri: process.env.WIKIMEDIA_CALLBACK_URL
})
});

const tokenData = await tokenResponse.json();
if (!tokenResponse.ok) {
console.error('Token Error:', tokenData);
return res.redirect(`${process.env.FRONTEND_URL}/auth?error=Authentication failed`);
}

const profileResponse = await fetch('https://meta.wikimedia.org/w/rest.php/oauth2/resource/profile', {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
'User-Agent': userAgent
}
});
const profile = await profileResponse.json();
if (!profileResponse.ok) {
console.error('Profile Error:', profile);
return res.redirect(`${process.env.FRONTEND_URL}/auth?error=Failed to fetch profile`);
}

let user = await User.findOne({ wikimedia_id: profile.sub });

if (!user) {
let finalUsername = profile.username;
const existingUser = await User.findOne({ username: profile.username });
if (existingUser) {
finalUsername = `${profile.username}_${Math.random().toString(36).substring(7)}`;
}

user = await User.create({
username: finalUsername,
email: profile.email || `${finalUsername}@example.com`,
country: 'Global',
wikimedia_id: profile.sub,
wikimedia_username: profile.username,
isActive: true
});
} else if (!user.isActive) {
return res.redirect(`${process.env.FRONTEND_URL}/auth?error=Account deactivated`);
}

const accessToken = generateAccessToken(user._id);
const refreshToken = generateRefreshToken(user._id);

res.redirect(`${process.env.FRONTEND_URL}/auth/callback?accessToken=${accessToken}&refreshToken=${refreshToken}`);

Comment on lines +268 to +272
} catch (error) {
console.error('Wikimedia OAuth catch error:', error);
res.redirect(`${process.env.FRONTEND_URL}/auth?error=Server error`);
}
};

15 changes: 13 additions & 2 deletions backend/src/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,21 @@ const userSchema = new mongoose.Schema({
},
password: {
type: String,
required: [true, 'Password is required'],
required: [
function() { return !this.wikimedia_id; },
'Password is required'
],
minlength: [6, 'Password must be at least 6 characters'],
select: false
},
wikimedia_id: {
type: String,
unique: true,
sparse: true
},
wikimedia_username: {
type: String
},
country: {
type: String,
required: [true, 'Country is required']
Expand Down Expand Up @@ -63,7 +74,7 @@ const userSchema = new mongoose.Schema({

// Hash password before saving
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) {
if (!this.isModified('password') || !this.password) {
return next();
}
Comment on lines 76 to 79

Expand Down
6 changes: 5 additions & 1 deletion backend/src/routes/authRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
getMe,
refreshToken,
updateProfile,
changePassword
changePassword,
wikimediaLogin,
wikimediaCallback
} from '../controllers/authController.js';
import { protect } from '../middleware/auth.js';
import { registerValidation, loginValidation, validate } from '../middleware/validator.js';
Expand All @@ -15,6 +17,8 @@ const router = express.Router();

router.post('/register', registerValidation, validate, register);
router.post('/login', loginValidation, validate, login);
router.get('/wikimedia', wikimediaLogin);
router.get('/wikimedia/callback', wikimediaCallback);
Comment on lines +20 to +21
router.post('/logout', protect, logout);
router.get('/me', protect, getMe);
router.post('/refresh', refreshToken);
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
PublicDirectory,
UserProfile,
CountryPage,
OAuthCallback,
} from "./pages";
import { Toaster } from './components/ui/sonner';
import { TooltipProvider } from './components/ui/tooltip';
Expand Down Expand Up @@ -48,6 +49,7 @@ function AppContent() {
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/auth/callback" element={<OAuthCallback />} />
<Route path="/submit" element={<SubmissionForm />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/directory" element={<PublicDirectory />} />
Expand Down
22 changes: 21 additions & 1 deletion frontend/src/lib/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface AuthContextType {
user: User | null;
loading: boolean;
login: (username: string, password: string) => Promise<boolean>;
loginWithTokens: (accessToken: string, refreshToken: string) => Promise<boolean>;
logout: () => void;
register: (username: string, email: string, password: string, country: string) => Promise<boolean>;
updateUser: (updates: Partial<User>) => void;
Expand Down Expand Up @@ -66,6 +67,25 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
};

const loginWithTokens = async (accessToken: string, refreshToken: string): Promise<boolean> => {
try {
api.setTokens(accessToken, refreshToken);
const response = await authApi.getMe();
if (response.success && response.user) {
setUser(response.user);
toast.success('Login successful!');
return true;
}
api.clearAuth();
toast.error('Failed to get user profile');
return false;
} catch (error) {
api.clearAuth();
toast.error('Login failed');
return false;
}
};

const logout = async () => {
try {
await authApi.logout();
Expand Down Expand Up @@ -122,7 +142,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
};

return (
<AuthContext.Provider value={{ user, loading, login, logout, register, updateUser }}>
<AuthContext.Provider value={{ user, loading, login, loginWithTokens, logout, register, updateUser }}>
{children}
</AuthContext.Provider>
);
Expand Down
37 changes: 36 additions & 1 deletion frontend/src/pages/AuthPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,29 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
import { useAuth } from '../lib/auth-context';
import { COUNTRIES } from '../lib/mock-data';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
import { Loader2, Globe } from 'lucide-react';
import { useEffect } from 'react';

export const AuthPage: React.FC = () => {
const navigate = useNavigate();
const { login, register } = useAuth();
const [loading, setLoading] = useState(false);

useEffect(() => {
// Check for errors from OAuth redirect
const params = new URLSearchParams(window.location.search);
const error = params.get('error');
if (error) {
toast.error(error);
}
}, []);
Comment on lines +20 to +27

const handleWikimediaLogin = () => {
window.location.href = import.meta.env.VITE_API_URL
? `${import.meta.env.VITE_API_URL}/auth/wikimedia`
: 'http://localhost:5000/api/auth/wikimedia';
};

// Login form state
const [loginUsername, setLoginUsername] = useState('');
const [loginPassword, setLoginPassword] = useState('');
Expand Down Expand Up @@ -72,6 +88,25 @@ export const AuthPage: React.FC = () => {
<p className="text-gray-600">Sign in or create an account to get started</p>
</div>

<div className="mb-6">
<Button
onClick={handleWikimediaLogin}
className="w-full !bg-blue-600 hover:!bg-blue-700 text-white flex items-center justify-center gap-2 border-none"
>
<Globe className="w-5 h-5" />
Sign in with Wikimedia
</Button>

<div className="relative mt-6 mb-6">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-gray-50 px-2 text-gray-500">Or continue with</span>
</div>
</div>
</div>

<Tabs defaultValue="login" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="login">Login</TabsTrigger>
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/pages/OAuthCallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React, { useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../lib/auth-context';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';

export const OAuthCallback: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { loginWithTokens } = useAuth();

// Use a ref to prevent double execution in strict mode
const processed = useRef(false);

useEffect(() => {
if (processed.current) return;
processed.current = true;

const accessToken = searchParams.get('accessToken');
const refreshToken = searchParams.get('refreshToken');
const error = searchParams.get('error');
Comment on lines +19 to +21

if (error) {
toast.error(`Authentication failed: ${error}`);
navigate('/auth');
return;
Comment on lines +24 to +26
}

if (accessToken && refreshToken) {
loginWithTokens(accessToken, refreshToken).then(success => {
if (success) {
navigate('/');
} else {
navigate('/auth');
}
});
} else {
Comment on lines +29 to +37
toast.error('Authentication failed: Missing tokens');
navigate('/auth');
}
Comment on lines +38 to +40
}, [searchParams, navigate, loginWithTokens]);

return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center p-4">
<Loader2 className="w-12 h-12 text-blue-600 animate-spin mb-4" />
<h2 className="text-xl font-semibold text-gray-800">Completing sign in...</h2>
<p className="text-gray-500 mt-2">Please wait while we redirect you.</p>
</div>
);
};
1 change: 1 addition & 0 deletions frontend/src/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export { AdminDashboard } from './AdminDashboard';
export { PublicDirectory } from './PublicDirectory';
export { UserProfile } from './UserProfile';
export { CountryPage } from './CountryPage';
export { OAuthCallback } from './OAuthCallback';