-
Notifications
You must be signed in to change notification settings - Fork 315
Full-Stack Authentication App #246
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
042d6f5
6e9a495
d8181ff
e8a4fa2
f00b5fa
6f3be6f
8293376
7538714
5280dd2
5dc589b
0177594
a643298
7575438
4c1051c
c8e3f26
2817ca7
8151167
50cb297
7a5d8a8
9f0abfb
eefd4ed
78264b5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| PORT=3005 | ||
| DB_HOST=localhost | ||
| DB_USER=postgres | ||
| DB_PASSWORD= | ||
| DB_DATABASE=postgres | ||
| CLIENT_HOST=http://localhost:5173 | ||
| JWT_KEY= | ||
| JWT_REFRESH_KEY= | ||
| SMTP_HOST=smtp.gmail.com | ||
| SMTP_PORT=587 | ||
| SMTP_USER= | ||
| SMTP_PASSWORD= | ||
| GOOGLE_CLIENT_ID= | ||
| GOOGLE_CLIENT_SECRET= |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| name: Test | ||
|
|
||
| on: | ||
| pull_request: | ||
| branches: [ master ] | ||
|
|
||
| jobs: | ||
| build: | ||
|
|
||
| runs-on: ubuntu-latest | ||
|
|
||
| strategy: | ||
| matrix: | ||
| node-version: [20.x] | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v2 | ||
| - name: Use Node.js ${{ matrix.node-version }} | ||
| uses: actions/setup-node@v1 | ||
| with: | ||
| node-version: ${{ matrix.node-version }} | ||
| - run: npm install | ||
| - run: npm test |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,3 +7,6 @@ node_modules | |
|
|
||
| # MacOS | ||
| .DS_Store | ||
| .env | ||
| backend/.env | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| /node_modules | ||
| /dist |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| { | ||
| "tabWidth": 2, | ||
| "useTabs": false, | ||
| "semi": true, | ||
| "singleQuote": true, | ||
| "trailingComma": "all", | ||
| "printWidth": 80, | ||
| "endOfLine": "auto" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import 'dotenv/config'; | ||
| import { client } from './backend/src/utils/db.js'; | ||
| import './backend/src/models/user.js'; | ||
| import './backend/src/models/token.js'; | ||
|
Comment on lines
+2
to
+4
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These import paths are incorrect. This script is located in the |
||
|
|
||
| await client.sync({ alter: true }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| import { ApiError } from '../exeptions/api.error.js'; | ||
| import { User } from '../models/user.js'; | ||
| import { jwtService } from '../services/jwt.service.js'; | ||
| import { tokenService } from '../services/token.service.js'; | ||
| import { userService } from '../services/user.service.js'; | ||
| import { emailService } from '../services/email.service.js'; | ||
| import { v4 as uuidv4 } from 'uuid'; | ||
| import bcrypt from 'bcrypt'; | ||
|
|
||
| function validateEmail(value) { | ||
| if (!value) { | ||
| return 'Email is required'; | ||
| } | ||
|
|
||
| const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; | ||
|
|
||
| if (!emailPattern.test(value)) { | ||
| return 'Email is not valid'; | ||
| } | ||
| } | ||
|
|
||
| function validatePassword(value) { | ||
| if (!value) { | ||
| return 'Password is required'; | ||
| } | ||
|
|
||
| if (value.length < 6) { | ||
| return 'At least 6 characters'; | ||
| } | ||
| } | ||
|
|
||
| const register = async (req, res) => { | ||
| const { email, password, name } = req.body; | ||
|
|
||
| const errors = { | ||
| email: validateEmail(email), | ||
| password: validatePassword(password), | ||
| }; | ||
|
|
||
| if (errors.email || errors.password) { | ||
| throw ApiError.badRequest('Bad request', errors); | ||
| } | ||
|
|
||
| const hashedPass = await bcrypt.hash(password, 10); | ||
|
|
||
| await userService.register({ email, password: hashedPass, name }); | ||
| res.send({ message: 'OK' }); | ||
| }; | ||
|
|
||
| const activate = async (req, res) => { | ||
| const { activationToken } = req.params; | ||
| const user = await User.findOne({ where: { activationToken } }); | ||
|
|
||
| if (!user) { | ||
| return res.status(404).send({ message: 'User not found' }); | ||
| } | ||
|
|
||
| user.activationToken = null; | ||
| await user.save(); | ||
|
|
||
| const { accessToken, user: normalizedUser } = await generateTokens(res, user); | ||
|
|
||
| res.json({ accessToken, user: normalizedUser }); | ||
| }; | ||
|
|
||
| const login = async (req, res) => { | ||
| const { email, password } = req.body; | ||
| const user = await userService.findByEmail(email); | ||
|
|
||
| if (!user) { | ||
| throw ApiError.badRequest('No such user'); | ||
| } | ||
|
|
||
| if (user.activationToken) { | ||
| throw ApiError.badRequest('Please activate your email'); | ||
| } | ||
|
|
||
| const isPasswordValid = await bcrypt.compare(password, user.password); | ||
|
|
||
| if (!isPasswordValid) { | ||
| throw ApiError.badRequest('Wrong password'); | ||
| } | ||
|
|
||
| const { accessToken, user: normalizedUser } = await generateTokens(res, user); | ||
|
|
||
| res.json({ accessToken, user: normalizedUser }); | ||
| }; | ||
|
|
||
| const refresh = async (req, res) => { | ||
| const { refreshToken } = req.cookies; | ||
| const userData = await jwtService.verifyRefresh(refreshToken); | ||
| const token = await tokenService.getByToken(refreshToken); | ||
|
|
||
| if (!userData || !token) { | ||
| throw ApiError.unAuthorized(); | ||
| } | ||
|
|
||
| const user = await userService.findByEmail(userData.email); | ||
| const { accessToken, user: normalizedUser } = await generateTokens(res, user); | ||
|
|
||
| res.json({ accessToken, user: normalizedUser }); | ||
| }; | ||
|
|
||
| export async function generateTokens(res, user) { | ||
| const normalizedUser = userService.normalize(user); | ||
| const refreshToken = jwtService.signRefresh(normalizedUser); | ||
| const accessToken = jwtService.sign(normalizedUser); | ||
|
|
||
| await tokenService.save(normalizedUser.id, refreshToken); | ||
|
|
||
| res.cookie('refreshToken', refreshToken, { | ||
| maxAge: 30 * 24 * 60 * 60 * 1000, | ||
| httpOnly: true, | ||
| }); | ||
|
|
||
| return { accessToken, user: normalizedUser }; | ||
| } | ||
|
|
||
| const logout = async (req, res) => { | ||
| const { refreshToken } = req.cookies; | ||
| const userData = await jwtService.verifyRefresh(refreshToken); | ||
|
|
||
| if (!userData || !refreshToken) { | ||
| throw ApiError.unAuthorized(); | ||
| } | ||
|
|
||
| await tokenService.remove(userData.id); | ||
|
|
||
| res.sendStatus(204); | ||
| }; | ||
|
|
||
| const forgotPassword = async (req, res) => { | ||
| const { email } = req.body; | ||
| const user = await userService.findByEmail(email); | ||
|
|
||
| if (!user) { | ||
| throw ApiError.badRequest('No such user'); | ||
| } | ||
|
|
||
| const resetToken = uuidv4(); | ||
|
|
||
| user.resetToken = resetToken; | ||
| await user.save(); | ||
|
|
||
| await emailService.sendResetEmail(email, resetToken); | ||
| res.send({ message: 'OK' }); | ||
| }; | ||
|
|
||
| const resetPassword = async (req, res) => { | ||
| const { token } = req.params; | ||
| const { password } = req.body; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When resetting a password, it's important to validate the new password to ensure it meets the required criteria (e.g., minimum length). You can reuse the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. According to the task requirements, the password reset functionality must validate that the |
||
|
|
||
| const user = await User.findOne({ where: { resetToken: token } }); | ||
|
|
||
| if (!user) { | ||
| throw ApiError.badRequest('Invalid token'); | ||
| } | ||
|
|
||
| user.password = await bcrypt.hash(password, 10); | ||
| user.resetToken = null; | ||
| await user.save(); | ||
|
|
||
| res.send({ message: 'OK' }); | ||
| }; | ||
|
|
||
| export const authController = { | ||
| register, | ||
| activate, | ||
| login, | ||
| logout, | ||
| refresh, | ||
| resetPassword, | ||
| forgotPassword, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import bcrypt from 'bcrypt'; | ||
| import { ApiError } from '../exeptions/api.error.js'; | ||
| import { User } from '../models/user.js'; | ||
| import { userService } from '../services/user.service.js'; | ||
| import { emailService } from '../services/email.service.js'; | ||
|
|
||
| const getAllActivated = async (req, res) => { | ||
| const users = await userService.getAllActivated(); | ||
|
|
||
| res.send(users.map(userService.normalize)); | ||
| }; | ||
|
|
||
| const updateName = async (req, res) => { | ||
| const { name } = req.body; | ||
| const { id } = req.user; | ||
|
|
||
| const user = await User.findByPk(id); | ||
|
|
||
| user.name = name; | ||
| await user.save(); | ||
|
|
||
| res.send(userService.normalize(user)); | ||
| }; | ||
|
|
||
| const changePassword = async (req, res) => { | ||
| const { password, newPassword } = req.body; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The task requires the change password functionality to include fields for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The task requires a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The task requires that changing a password includes a |
||
| const { id } = req.user; | ||
|
|
||
| const user = await User.findByPk(id); | ||
|
|
||
| if (!user.password) { | ||
| throw ApiError.badRequest('У вас немає пароля — ви увійшли через Google'); | ||
| } | ||
|
|
||
| const isValid = await bcrypt.compare(password, user.password); | ||
|
|
||
| if (!isValid) { | ||
| throw ApiError.badRequest('Wrong password'); | ||
| } | ||
|
|
||
| user.password = await bcrypt.hash(newPassword, 10); | ||
| await user.save(); | ||
|
|
||
| res.send({ message: 'OK' }); | ||
| }; | ||
|
|
||
| const changeEmail = async (req, res) => { | ||
| const { password, newEmail } = req.body; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. According to the requirements, changing an email should involve confirming the new email address. This implementation is missing that confirmation step. You should expect a confirmation field for the new email (e.g., There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. According to the task description, the user must confirm their new email address. This implementation is missing a field for email confirmation. Please add a check to ensure the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The requirements state that to change an email, the user must "confirm the new email". This implies that you should expect a confirmation field for the |
||
| const { id } = req.user; | ||
|
|
||
| const user = await User.findByPk(id); | ||
| const isValid = await bcrypt.compare(password, user.password); | ||
|
|
||
| if (!isValid) { | ||
| throw ApiError.badRequest('Wrong password'); | ||
| } | ||
|
|
||
| const existUser = await userService.findByEmail(newEmail); | ||
|
|
||
| if (existUser) { | ||
| throw ApiError.badRequest('Email already in use'); | ||
| } | ||
|
|
||
| await emailService.sendEmailChangeNotification(user.email, newEmail); | ||
|
|
||
| user.email = newEmail; | ||
| await user.save(); | ||
|
|
||
| res.send(userService.normalize(user)); | ||
| }; | ||
|
|
||
| export const userController = { | ||
| getAllActivated, | ||
| updateName, | ||
| changePassword, | ||
| changeEmail, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| export class ApiError extends Error { | ||
| constructor({ message, status, errors = {} }) { | ||
| super(message); | ||
|
|
||
| this.status = status; | ||
| this.errors = errors; | ||
| } | ||
|
|
||
| static badRequest(message, errors) { | ||
| return new ApiError({ | ||
| message, | ||
| errors, | ||
| status: 400, | ||
| }); | ||
| } | ||
|
|
||
| static unAuthorized(errors) { | ||
| return new ApiError({ | ||
| message: 'Unauthorized user', | ||
| errors, | ||
| status: 401, | ||
| }); | ||
| } | ||
|
|
||
| static notFound(errors) { | ||
| return new ApiError({ | ||
| message: 'Not found', | ||
| errors, | ||
| status: 404, | ||
| }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| 'use strict'; | ||
| import 'dotenv/config'; | ||
| import express from 'express'; | ||
| import { authRouter } from './routes/auth.route.js'; | ||
| import cors from 'cors'; | ||
| import { userRouter } from './routes/user.route.js'; | ||
| import { errorMiddleware } from './middlewares/errorMiddleware.js'; | ||
| import cookieParser from 'cookie-parser'; | ||
| import passport from './utils/passport.js'; | ||
|
|
||
| const PORT = process.env.PORT || 3005; | ||
|
|
||
| const app = express(); | ||
|
|
||
| app.use(express.json()); | ||
| app.use(cookieParser()); | ||
| app.use(passport.initialize()); | ||
|
|
||
| app.use( | ||
| cors({ | ||
| origin: process.env.CLIENT_HOST, | ||
| credentials: true, | ||
| }), | ||
| ); | ||
| app.use('/auth', authRouter); | ||
| app.use('/users', userRouter); | ||
|
|
||
| app.get('/', (req, res) => { | ||
| res.send('Hello World!'); | ||
| }); | ||
|
|
||
| app.use(errorMiddleware); | ||
|
|
||
| app.listen(PORT); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good job ignoring the environment file for the backend. To ensure frontend secrets are also kept safe, you should add an entry for the frontend's environment file as well, for example
frontend/.env.