Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -49,6 +52,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.hasRole("student")
.anyRequest()
.authenticated())
.addFilterBefore(apiKeyAuthenticationFilter(userAuthenticationService), UsernamePasswordAuthenticationFilter.class)
.build();
}

Expand All @@ -62,4 +66,9 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration a
throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}

@Bean
public ApiKeyAuthenticationFilter apiKeyAuthenticationFilter(UserAuthenticationService userAuthenticationService) {
return new ApiKeyAuthenticationFilter(userAuthenticationService);
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand All @@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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 AUTHORIZATION_HEADER_KEY = "Authorization";

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(AUTHORIZATION_HEADER_KEY);

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 API key", 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());
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Object> 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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
@@ -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;

37 changes: 16 additions & 21 deletions frontend/src/pages/Profile.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const {backendUrl} = useConfig();

if (!user) {
return <Navigate to="/login" />;
Expand All @@ -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);
}
};
Expand Down