From c58e29cbd8e59cbd5d8f7c97a32ec153ad421ea4 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Tue, 31 Jan 2023 11:53:06 +0100 Subject: [PATCH] Added proper login and registration flows. --- docker-compose.yml | 9 ++ gymboard-api/pom.xml | 4 + .../gymboard_api/config/SecurityConfig.java | 4 +- .../controller/AuthController.java | 33 ++++++- .../controller/dto/UserActivationPayload.java | 3 + .../auth/UserActivationCodeRepository.java | 12 +++ .../gymboard_api/model/SampleDataLoader.java | 2 +- .../gymboard_api/model/auth/User.java | 4 + .../model/auth/UserActivationCode.java | 46 +++++++++ .../service/auth/UserService.java | 90 +++++++++++++++++- .../application-development.properties | 8 +- gymboard-app/quasar.config.js | 4 +- gymboard-app/src/api/main/auth.ts | 16 ++++ gymboard-app/src/boot/i18n.ts | 15 ++- .../src/components/AccountMenuItem.vue | 4 +- gymboard-app/src/i18n/en-US/index.ts | 19 ++++ gymboard-app/src/i18n/nl-NL/index.ts | 19 ++++ .../src/pages/auth/ActivationPage.vue | 40 ++++++++ .../src/pages/{ => auth}/LoginPage.vue | 16 ++-- gymboard-app/src/pages/auth/RegisterPage.vue | 94 +++++++++++++++++++ .../pages/auth/RegistrationSuccessPage.vue | 15 +++ gymboard-app/src/router/routes.ts | 13 ++- 22 files changed, 447 insertions(+), 23 deletions(-) create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserActivationPayload.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/UserActivationCodeRepository.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/UserActivationCode.java create mode 100644 gymboard-app/src/pages/auth/ActivationPage.vue rename gymboard-app/src/pages/{ => auth}/LoginPage.vue (76%) create mode 100644 gymboard-app/src/pages/auth/RegisterPage.vue create mode 100644 gymboard-app/src/pages/auth/RegistrationSuccessPage.vue diff --git a/docker-compose.yml b/docker-compose.yml index 5175a26..9cb3126 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,3 +12,12 @@ services: environment: POSTGRES_USER: gymboard-api-dev POSTGRES_PASSWORD: testpass + + mailhog: + image: mailhog/mailhog + restart: always + expose: + - "8025" + ports: + - "1025:1025" # SMTP + - "8025:8025" # Web UI diff --git a/gymboard-api/pom.xml b/gymboard-api/pom.xml index 60f0eb6..43d5ff0 100644 --- a/gymboard-api/pom.xml +++ b/gymboard-api/pom.xml @@ -37,6 +37,10 @@ org.springframework.boot spring-boot-starter-websocket + + org.springframework.boot + spring-boot-starter-mail + org.postgresql diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java index 2e79713..2f25e8e 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java @@ -51,7 +51,9 @@ public class SecurityConfig { HttpMethod.POST, "/gyms/submissions", "/gyms/submissions/upload", - "/auth/token" + "/auth/token", + "/auth/register", + "/auth/activate" ).permitAll() // Everything else must be authenticated, just to be safe. .anyRequest().authenticated(); diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java index da03fe3..6ecc175 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java @@ -1,10 +1,9 @@ package nl.andrewlalis.gymboard_api.controller; -import nl.andrewlalis.gymboard_api.controller.dto.TokenCredentials; -import nl.andrewlalis.gymboard_api.controller.dto.TokenResponse; -import nl.andrewlalis.gymboard_api.controller.dto.UserResponse; +import nl.andrewlalis.gymboard_api.controller.dto.*; import nl.andrewlalis.gymboard_api.model.auth.User; import nl.andrewlalis.gymboard_api.service.auth.TokenService; +import nl.andrewlalis.gymboard_api.service.auth.UserService; import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; @@ -15,9 +14,35 @@ import org.springframework.web.bind.annotation.RestController; @RestController public class AuthController { private final TokenService tokenService; + private final UserService userService; - public AuthController(TokenService tokenService) { + public AuthController(TokenService tokenService, UserService userService) { this.tokenService = tokenService; + this.userService = userService; + } + + /** + * Endpoint for registering a new user in the system. This is a + * public endpoint. If the user is successfully created, an email + * will be sent to them with a link for activating the account. + * @param payload The payload. + * @return The created user. + */ + @PostMapping(path = "/auth/register") + public UserResponse registerNewUser(@RequestBody UserCreationPayload payload) { + return userService.createUser(payload, true); + } + + /** + * Endpoint for activating a new user via an activation code. This + * is a public endpoint. If the code is recent (within 24 hours) + * and the user exists, then they'll be activated and able to log in. + * @param payload The payload containing the activation code. + * @return The activated user. + */ + @PostMapping(path = "/auth/activate") + public UserResponse activateUser(@RequestBody UserActivationPayload payload) { + return userService.activateUser(payload); } /** diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserActivationPayload.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserActivationPayload.java new file mode 100644 index 0000000..5f00816 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserActivationPayload.java @@ -0,0 +1,3 @@ +package nl.andrewlalis.gymboard_api.controller.dto; + +public record UserActivationPayload(String code) {} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/UserActivationCodeRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/UserActivationCodeRepository.java new file mode 100644 index 0000000..d1191aa --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/UserActivationCodeRepository.java @@ -0,0 +1,12 @@ +package nl.andrewlalis.gymboard_api.dao.auth; + +import nl.andrewlalis.gymboard_api.model.auth.UserActivationCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserActivationCodeRepository extends JpaRepository { + Optional findByCode(String code); +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java index 907ef7f..9433701 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java @@ -144,7 +144,7 @@ public class SampleDataLoader implements ApplicationListenerHello %s,

+ +

+ Thank you for registering a new account at Gymboard! +

+

+ Please click here to activate your account. +

+ """, + user.getName(), + activationLink + ); + MimeMessage msg = mailSender.createMimeMessage(); + try { + MimeMessageHelper helper = new MimeMessageHelper(msg, "UTF-8"); + helper.setFrom("Gymboard "); + helper.setSubject("Activate Your Gymboard Account"); + helper.setTo(user.getEmail()); + helper.setText(emailContent, true); + mailSender.send(msg); + } catch (MessagingException e) { + log.error("Error sending user activation email.", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Transactional + public UserResponse activateUser(UserActivationPayload payload) { + UserActivationCode activationCode = activationCodeRepository.findByCode(payload.code()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); + LocalDateTime cutoff = LocalDateTime.now().minusDays(1); + if (activationCode.getCreatedAt().isBefore(cutoff)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Code is expired."); + } + User user = activationCode.getUser(); + user.setActivated(true); + userRepository.save(user); return new UserResponse(user); } } diff --git a/gymboard-api/src/main/resources/application-development.properties b/gymboard-api/src/main/resources/application-development.properties index 974c949..ffb41c6 100644 --- a/gymboard-api/src/main/resources/application-development.properties +++ b/gymboard-api/src/main/resources/application-development.properties @@ -9,5 +9,11 @@ spring.jpa.show-sql=false spring.task.execution.pool.core-size=3 spring.task.execution.pool.max-size=10 -app.auth.private-key-location=./private_key.der +spring.mail.host=127.0.0.1 +spring.mail.port=1025 +spring.mail.protocol=smtp +spring.mail.properties.mail.smtp.timeout=10000 + +app.auth.private-key-location=./private_key.der +app.web-origin=http://localhost:9000 diff --git a/gymboard-app/quasar.config.js b/gymboard-app/quasar.config.js index bd2275d..4a68346 100644 --- a/gymboard-app/quasar.config.js +++ b/gymboard-app/quasar.config.js @@ -111,7 +111,9 @@ module.exports = configure(function (ctx) { // directives: [], // Quasar plugins - plugins: [], + plugins: [ + 'Notify' + ], }, // animations: 'all', // --- includes all animations diff --git a/gymboard-app/src/api/main/auth.ts b/gymboard-app/src/api/main/auth.ts index 7f3e6c3..a51abad 100644 --- a/gymboard-app/src/api/main/auth.ts +++ b/gymboard-app/src/api/main/auth.ts @@ -14,6 +14,12 @@ export interface TokenCredentials { password: string; } +export interface UserCreationPayload { + name: string; + email: string; + password: string; +} + class AuthModule { private static readonly TOKEN_REFRESH_INTERVAL_MS = 30000; @@ -35,6 +41,16 @@ class AuthModule { clearTimeout(this.tokenRefreshTimer); } + public async register(payload: UserCreationPayload) { + const response = await api.post('/auth/register', payload); + console.log(response); + } + + public async activateUser(code: string): Promise { + const response = await api.post('/auth/activate', {code: code}); + return response.data; + } + private async fetchNewToken(credentials: TokenCredentials): Promise { const response = await api.post('/auth/token', credentials); return response.data.token; diff --git a/gymboard-app/src/boot/i18n.ts b/gymboard-app/src/boot/i18n.ts index 6a844a1..58f6716 100644 --- a/gymboard-app/src/boot/i18n.ts +++ b/gymboard-app/src/boot/i18n.ts @@ -1,5 +1,5 @@ -import { boot } from 'quasar/wrappers'; -import { createI18n } from 'vue-i18n'; +import {boot} from 'quasar/wrappers'; +import {createI18n} from 'vue-i18n'; import messages from 'src/i18n'; @@ -28,6 +28,17 @@ export default boot(({ app }) => { messages, }); + // Set the locale to the preferred locale, if possible. + const userLocale = window.navigator.language; + if (userLocale === 'nl-NL') { + i18n.global.locale.value = userLocale; + } else { + i18n.global.locale.value = 'en-US'; + } + + // Temporary override if you want to test a particular locale. + i18n.global.locale.value = 'nl-NL'; + // Set i18n instance on app app.use(i18n); }); diff --git a/gymboard-app/src/components/AccountMenuItem.vue b/gymboard-app/src/components/AccountMenuItem.vue index b5dcd13..8d7065b 100644 --- a/gymboard-app/src/components/AccountMenuItem.vue +++ b/gymboard-app/src/components/AccountMenuItem.vue @@ -10,14 +10,14 @@ - Log out + {{ $t('accountMenuItem.logOut') }} + +

{{ statusText }}

+
+ + + + + diff --git a/gymboard-app/src/pages/LoginPage.vue b/gymboard-app/src/pages/auth/LoginPage.vue similarity index 76% rename from gymboard-app/src/pages/LoginPage.vue rename to gymboard-app/src/pages/auth/LoginPage.vue index ce07ac5..6e5d769 100644 --- a/gymboard-app/src/pages/LoginPage.vue +++ b/gymboard-app/src/pages/auth/LoginPage.vue @@ -1,12 +1,13 @@ diff --git a/gymboard-app/src/pages/auth/RegistrationSuccessPage.vue b/gymboard-app/src/pages/auth/RegistrationSuccessPage.vue new file mode 100644 index 0000000..23a5ab0 --- /dev/null +++ b/gymboard-app/src/pages/auth/RegistrationSuccessPage.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/gymboard-app/src/router/routes.ts b/gymboard-app/src/router/routes.ts index 3ae9fc4..20de51f 100644 --- a/gymboard-app/src/router/routes.ts +++ b/gymboard-app/src/router/routes.ts @@ -6,9 +6,19 @@ import GymSubmissionPage from 'pages/gym/GymSubmissionPage.vue'; import GymHomePage from 'pages/gym/GymHomePage.vue'; import GymLeaderboardsPage from 'pages/gym/GymLeaderboardsPage.vue'; import TestingPage from 'pages/TestingPage.vue'; -import LoginPage from 'pages/LoginPage.vue'; +import LoginPage from 'pages/auth/LoginPage.vue'; +import RegisterPage from "pages/auth/RegisterPage.vue"; +import RegistrationSuccessPage from "pages/auth/RegistrationSuccessPage.vue"; +import ActivationPage from "pages/auth/ActivationPage.vue"; const routes: RouteRecordRaw[] = [ + // Auth-related pages, which live outside the main layout. + { path: '/login', component: LoginPage }, + { path: '/register', component: RegisterPage }, + { path: '/register/success', component: RegistrationSuccessPage }, + { path: '/activate', component: ActivationPage }, + + // Main app: { path: '/', component: MainLayout, @@ -26,7 +36,6 @@ const routes: RouteRecordRaw[] = [ }, ], }, - { path: '/login', component: LoginPage }, // Always leave this as last one, // but you can also remove it