From e3aa5e8caeddf7eda2d90ae8cb740e796e4041de Mon Sep 17 00:00:00 2001 From: breus Date: Sun, 19 Oct 2025 21:27:38 +0200 Subject: [PATCH 1/3] Finish functionality to generate API key and authenticate with it --- .../coursehub/SecurityConfig.java | 11 +++- .../coursehub/data/UserAccountEntity.java | 2 +- .../repository/UserAccountRepository.java | 38 +++++++++++- .../security/ApiKeyAuthenticationFilter.java | 59 +++++++++++++++++++ .../service/UserAuthenticationService.java | 57 ++++++++++++++++++ .../web/UserAuthenticationController.java | 14 ++++- .../coursehub/web/model/ApiKeyResponse.java | 8 +++ .../db/migration/V3__add_api_key_column.sql | 4 ++ frontend/src/pages/Profile.tsx | 37 +++++------- 9 files changed, 204 insertions(+), 26 deletions(-) create mode 100644 backend/src/main/java/net/hackyourfuture/coursehub/security/ApiKeyAuthenticationFilter.java create mode 100644 backend/src/main/java/net/hackyourfuture/coursehub/web/model/ApiKeyResponse.java create mode 100644 backend/src/main/resources/db/migration/V3__add_api_key_column.sql diff --git a/backend/src/main/java/net/hackyourfuture/coursehub/SecurityConfig.java b/backend/src/main/java/net/hackyourfuture/coursehub/SecurityConfig.java index cf79b6e..55220d2 100644 --- a/backend/src/main/java/net/hackyourfuture/coursehub/SecurityConfig.java +++ b/backend/src/main/java/net/hackyourfuture/coursehub/SecurityConfig.java @@ -1,5 +1,7 @@ package net.hackyourfuture.coursehub; +import net.hackyourfuture.coursehub.security.ApiKeyAuthenticationFilter; +import net.hackyourfuture.coursehub.service.UserAuthenticationService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -11,13 +13,14 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; @Configuration @EnableWebSecurity public class SecurityConfig { @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain(HttpSecurity http, UserAuthenticationService userAuthenticationService) throws Exception { return http.csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(request -> { var config = new CorsConfiguration(); @@ -49,6 +52,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .hasRole("student") .anyRequest() .authenticated()) + .addFilterBefore(apiKeyAuthenticationFilter(userAuthenticationService), UsernamePasswordAuthenticationFilter.class) .build(); } @@ -62,4 +66,9 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration a throws Exception { return authenticationConfiguration.getAuthenticationManager(); } + + @Bean + public ApiKeyAuthenticationFilter apiKeyAuthenticationFilter(UserAuthenticationService userAuthenticationService) { + return new ApiKeyAuthenticationFilter(userAuthenticationService); + } } diff --git a/backend/src/main/java/net/hackyourfuture/coursehub/data/UserAccountEntity.java b/backend/src/main/java/net/hackyourfuture/coursehub/data/UserAccountEntity.java index f726e03..1d02582 100644 --- a/backend/src/main/java/net/hackyourfuture/coursehub/data/UserAccountEntity.java +++ b/backend/src/main/java/net/hackyourfuture/coursehub/data/UserAccountEntity.java @@ -1,3 +1,3 @@ package net.hackyourfuture.coursehub.data; -public record UserAccountEntity(Integer userId, String emailAddress, String passwordHash, Role role) {} +public record UserAccountEntity(Integer userId, String emailAddress, String passwordHash, Role role, String apiKey) {} diff --git a/backend/src/main/java/net/hackyourfuture/coursehub/repository/UserAccountRepository.java b/backend/src/main/java/net/hackyourfuture/coursehub/repository/UserAccountRepository.java index 7c47796..286697c 100644 --- a/backend/src/main/java/net/hackyourfuture/coursehub/repository/UserAccountRepository.java +++ b/backend/src/main/java/net/hackyourfuture/coursehub/repository/UserAccountRepository.java @@ -17,7 +17,8 @@ public class UserAccountRepository { rs.getInt("user_id"), rs.getString("email_address"), rs.getString("password_hash"), - Role.valueOf(rs.getString("role"))); + Role.valueOf(rs.getString("role")), + rs.getString("api_key")); private final NamedParameterJdbcTemplate jdbcTemplate; public UserAccountRepository(NamedParameterJdbcTemplate jdbcTemplate) { @@ -48,7 +49,7 @@ public UserAccountEntity insertUserAccount(String emailAddress, String passwordH } String userSql = "INSERT INTO user_account (email_address, password_hash, role) " + "VALUES (:emailAddress, :passwordHash, :role::role) " - + "RETURNING user_id, email_address, password_hash, role"; + + "RETURNING user_id, email_address, password_hash, role, api_key"; return jdbcTemplate.queryForObject( userSql, Map.of( @@ -67,4 +68,37 @@ public Integer findUserIdByEmail(String emailAddress) { return null; } } + + /** + * Stores the given API key for the user with the given ID. + * + * @param userId the user ID + * @param apiKey the API key to store + * @return the updated UserAccountEntity + */ + @Transactional + public UserAccountEntity updateApiKey(Integer userId, String apiKey) { + String sql = "UPDATE user_account SET api_key = :apiKey WHERE user_id = :userId " + + "RETURNING user_id, email_address, password_hash, role, api_key"; + return jdbcTemplate.queryForObject( + sql, + Map.of("userId", userId, "apiKey", apiKey), + USER_ACCOUNT_ROW_MAPPER); + } + + /** + * Finds a user by API key. + * + * @param apiKey the API key + * @return the UserAccountEntity, or null if not found + */ + @Nullable + public UserAccountEntity findByApiKey(String apiKey) { + String sql = "SELECT * FROM user_account WHERE api_key = :apiKey"; + try { + return jdbcTemplate.queryForObject(sql, Map.of("apiKey", apiKey), USER_ACCOUNT_ROW_MAPPER); + } catch (EmptyResultDataAccessException e) { + return null; + } + } } diff --git a/backend/src/main/java/net/hackyourfuture/coursehub/security/ApiKeyAuthenticationFilter.java b/backend/src/main/java/net/hackyourfuture/coursehub/security/ApiKeyAuthenticationFilter.java new file mode 100644 index 0000000..d740fbf --- /dev/null +++ b/backend/src/main/java/net/hackyourfuture/coursehub/security/ApiKeyAuthenticationFilter.java @@ -0,0 +1,59 @@ +package net.hackyourfuture.coursehub.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import net.hackyourfuture.coursehub.data.AuthenticatedUser; +import net.hackyourfuture.coursehub.service.UserAuthenticationService; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class ApiKeyAuthenticationFilter extends OncePerRequestFilter { + + private final UserAuthenticationService userAuthenticationService; + private static final String API_KEY_HEADER = "X-API-Key"; + + public ApiKeyAuthenticationFilter(UserAuthenticationService userAuthenticationService) { + this.userAuthenticationService = userAuthenticationService; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + // Skip API key authentication if already authenticated + if (SecurityContextHolder.getContext().getAuthentication() != null && + SecurityContextHolder.getContext().getAuthentication().isAuthenticated()) { + filterChain.doFilter(request, response); + return; + } + + String apiKey = request.getHeader(API_KEY_HEADER); + + if (apiKey != null && !apiKey.isEmpty()) { + // Look up user by API key + AuthenticatedUser user = userAuthenticationService.findUserByApiKey(apiKey); + + if (user != null) { + // Create an authentication token + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + user, + null, // No credentials needed as we authenticated via API key + user.getAuthorities() + ); + + // Set authentication in context + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/net/hackyourfuture/coursehub/service/UserAuthenticationService.java b/backend/src/main/java/net/hackyourfuture/coursehub/service/UserAuthenticationService.java index 86c32b8..453c582 100644 --- a/backend/src/main/java/net/hackyourfuture/coursehub/service/UserAuthenticationService.java +++ b/backend/src/main/java/net/hackyourfuture/coursehub/service/UserAuthenticationService.java @@ -13,6 +13,10 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; + @Service public class UserAuthenticationService implements UserDetailsService { private final UserAccountRepository userAccountRepository; @@ -69,4 +73,57 @@ public void register(String firstName, String lastName, String emailAddress, Str var passwordHash = passwordEncoder.encode(password); studentRepository.insertStudent(firstName, lastName, emailAddress, passwordHash); } + + /** + * Generates a new API key for the current authenticated user. + * @return the generated API key + * @throws IllegalStateException if no user is authenticated + */ + public String generateApiKey() { + AuthenticatedUser authenticatedUser = currentAuthenticatedUser(); + if (authenticatedUser == null) { + throw new IllegalStateException("No authenticated user found"); + } + + // Generate a secure random API key with a prefix to identify it as an API key + byte[] randomBytes = new byte[32]; + try { + SecureRandom.getInstanceStrong().nextBytes(randomBytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Unable to generate an authentication token", e); + } + String apiKey = "chub_" + Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + + // Store the API key in the database + userAccountRepository.updateApiKey(authenticatedUser.getUserId(), apiKey); + + return apiKey; + } + + /** + * Finds a user by their API key. + * @param apiKey the API key + * @return the authenticated user or null if not found + */ + public AuthenticatedUser findUserByApiKey(String apiKey) { + UserAccountEntity userAccount = userAccountRepository.findByApiKey(apiKey); + if (userAccount == null) { + return null; + } + + return buildAuthenticatedUser(userAccount); + } + + private AuthenticatedUser buildAuthenticatedUser(UserAccountEntity userAccount) { + return switch (userAccount.role()) { + case student -> { + StudentEntity student = studentRepository.findById(userAccount.userId()); + yield new AuthenticatedUser(userAccount.userId(), student.firstName(), student.lastName(), userAccount.emailAddress(), userAccount.passwordHash(), userAccount.role()); + } + case instructor -> { + InstructorEntity instructor = instructorRepository.findById(userAccount.userId()); + yield new AuthenticatedUser(userAccount.userId(), instructor.firstName(), instructor.lastName(), userAccount.emailAddress(), userAccount.passwordHash(), userAccount.role()); + } + }; + } } diff --git a/backend/src/main/java/net/hackyourfuture/coursehub/web/UserAuthenticationController.java b/backend/src/main/java/net/hackyourfuture/coursehub/web/UserAuthenticationController.java index 61c605d..690eab7 100644 --- a/backend/src/main/java/net/hackyourfuture/coursehub/web/UserAuthenticationController.java +++ b/backend/src/main/java/net/hackyourfuture/coursehub/web/UserAuthenticationController.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import net.hackyourfuture.coursehub.service.UserAuthenticationService; +import net.hackyourfuture.coursehub.web.model.ApiKeyResponse; import net.hackyourfuture.coursehub.web.model.HttpErrorResponse; import net.hackyourfuture.coursehub.web.model.LoginRequest; import net.hackyourfuture.coursehub.web.model.LoginSuccessResponse; @@ -75,10 +76,21 @@ public LoginSuccessResponse register(@RequestBody RegisterRequest request, HttpS request.emailAddress(), request.password() ); - + // Authenticate the user and return the response return authenticate(httpRequest, httpResponse, request.emailAddress(), request.password()); } + @PostMapping("/generate-api-key") + public ResponseEntity generateApiKey() { + try { + String apiKey = userAuthenticationService.generateApiKey(); + return ResponseEntity.ok(new ApiKeyResponse(apiKey)); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new HttpErrorResponse("Unable to generate API key")); + } + } + private LoginSuccessResponse authenticate(HttpServletRequest request, HttpServletResponse response, String email, String password) { // Authenticate the user with the provided credentials (email and password) Authentication authentication = authenticationManager.authenticate( diff --git a/backend/src/main/java/net/hackyourfuture/coursehub/web/model/ApiKeyResponse.java b/backend/src/main/java/net/hackyourfuture/coursehub/web/model/ApiKeyResponse.java new file mode 100644 index 0000000..756a79f --- /dev/null +++ b/backend/src/main/java/net/hackyourfuture/coursehub/web/model/ApiKeyResponse.java @@ -0,0 +1,8 @@ +package net.hackyourfuture.coursehub.web.model; + +/** + * Response object for API key generation. + * @param apiKey The generated API key + */ +public record ApiKeyResponse(String apiKey) { +} diff --git a/backend/src/main/resources/db/migration/V3__add_api_key_column.sql b/backend/src/main/resources/db/migration/V3__add_api_key_column.sql new file mode 100644 index 0000000..1cd8349 --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__add_api_key_column.sql @@ -0,0 +1,4 @@ +-- Add API key column to user_account table +ALTER TABLE user_account ADD COLUMN api_key VARCHAR(100) NULL; +CREATE UNIQUE INDEX idx_api_key_unique ON user_account (api_key) WHERE api_key IS NOT NULL; + diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 3235886..225671a 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -1,11 +1,13 @@ -import React, { useState } from 'react'; -import { User } from '../types/User'; -import { Navigate } from 'react-router'; +import React, {useState} from 'react'; +import {User} from '../types/User'; +import {Navigate} from 'react-router'; +import {useConfig} from '../ConfigContext'; function Profile({ user }: { user: User | null }) { const [apiKey, setApiKey] = useState(null); const [isGenerating, setIsGenerating] = useState(false); const [error, setError] = useState(null); + const {backendUrl} = useConfig(); if (!user) { return ; @@ -16,27 +18,20 @@ function Profile({ user }: { user: User | null }) { setError(null); try { - // TODO: Replace with actual backend endpoint when implemented - // This is a placeholder that simulates API key generation - setTimeout(() => { - // Mock API key (in production this would come from the backend) - const mockApiKey = `key_${Math.random().toString(36).substring(2, 15)}`; - setApiKey(mockApiKey); - setIsGenerating(false); - }, 1000); + const response = await fetch(`${backendUrl}/generate-api-key`, { + method: 'POST', + credentials: 'include', + }); - // Actual API call would look like: - // const response = await fetch('http://localhost:8080/api/users/generate-api-key', { - // method: 'POST', - // credentials: 'include', - // headers: { - // 'Content-Type': 'application/json', - // } - // }); - // const data = await response.json(); - // setApiKey(data.apiKey); + if (!response.ok) { + throw new Error('Failed to generate API key'); + } + + const data = await response.json(); + setApiKey(data.apiKey); } catch (err) { setError('Failed to generate API key. Please try again later.'); + } finally { setIsGenerating(false); } }; From 8c2735293831fe915a70b3dc16f8e2364bc55ab1 Mon Sep 17 00:00:00 2001 From: breus Date: Sun, 19 Oct 2025 21:42:22 +0200 Subject: [PATCH 2/3] Small typo --- .../coursehub/service/UserAuthenticationService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/net/hackyourfuture/coursehub/service/UserAuthenticationService.java b/backend/src/main/java/net/hackyourfuture/coursehub/service/UserAuthenticationService.java index 453c582..e45e484 100644 --- a/backend/src/main/java/net/hackyourfuture/coursehub/service/UserAuthenticationService.java +++ b/backend/src/main/java/net/hackyourfuture/coursehub/service/UserAuthenticationService.java @@ -90,7 +90,7 @@ public String generateApiKey() { try { SecureRandom.getInstanceStrong().nextBytes(randomBytes); } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("Unable to generate an authentication token", e); + throw new IllegalStateException("Unable to generate an API key", e); } String apiKey = "chub_" + Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); From 10d30ec92bd732043ef9d5fe3ec71e711977a840 Mon Sep 17 00:00:00 2001 From: breus Date: Sun, 19 Oct 2025 21:59:29 +0200 Subject: [PATCH 3/3] User Authorization header --- .../coursehub/security/ApiKeyAuthenticationFilter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/net/hackyourfuture/coursehub/security/ApiKeyAuthenticationFilter.java b/backend/src/main/java/net/hackyourfuture/coursehub/security/ApiKeyAuthenticationFilter.java index d740fbf..4c89ca2 100644 --- a/backend/src/main/java/net/hackyourfuture/coursehub/security/ApiKeyAuthenticationFilter.java +++ b/backend/src/main/java/net/hackyourfuture/coursehub/security/ApiKeyAuthenticationFilter.java @@ -15,7 +15,7 @@ public class ApiKeyAuthenticationFilter extends OncePerRequestFilter { private final UserAuthenticationService userAuthenticationService; - private static final String API_KEY_HEADER = "X-API-Key"; + private static final String AUTHORIZATION_HEADER_KEY = "Authorization"; public ApiKeyAuthenticationFilter(UserAuthenticationService userAuthenticationService) { this.userAuthenticationService = userAuthenticationService; @@ -34,7 +34,7 @@ protected void doFilterInternal( return; } - String apiKey = request.getHeader(API_KEY_HEADER); + String apiKey = request.getHeader(AUTHORIZATION_HEADER_KEY); if (apiKey != null && !apiKey.isEmpty()) { // Look up user by API key