Skip to content

Commit 1e7f4f7

Browse files
authored
Finish functionality to generate API key and authenticate with it (#3)
1 parent 7892c9d commit 1e7f4f7

File tree

9 files changed

+204
-26
lines changed

9 files changed

+204
-26
lines changed

backend/src/main/java/net/hackyourfuture/coursehub/SecurityConfig.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package net.hackyourfuture.coursehub;
22

3+
import net.hackyourfuture.coursehub.security.ApiKeyAuthenticationFilter;
4+
import net.hackyourfuture.coursehub.service.UserAuthenticationService;
35
import org.springframework.context.annotation.Bean;
46
import org.springframework.context.annotation.Configuration;
57
import org.springframework.http.HttpMethod;
@@ -11,13 +13,14 @@
1113
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
1214
import org.springframework.security.crypto.password.PasswordEncoder;
1315
import org.springframework.security.web.SecurityFilterChain;
16+
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
1417
import org.springframework.web.cors.CorsConfiguration;
1518

1619
@Configuration
1720
@EnableWebSecurity
1821
public class SecurityConfig {
1922
@Bean
20-
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
23+
public SecurityFilterChain filterChain(HttpSecurity http, UserAuthenticationService userAuthenticationService) throws Exception {
2124
return http.csrf(AbstractHttpConfigurer::disable)
2225
.cors(cors -> cors.configurationSource(request -> {
2326
var config = new CorsConfiguration();
@@ -49,6 +52,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
4952
.hasRole("student")
5053
.anyRequest()
5154
.authenticated())
55+
.addFilterBefore(apiKeyAuthenticationFilter(userAuthenticationService), UsernamePasswordAuthenticationFilter.class)
5256
.build();
5357
}
5458

@@ -62,4 +66,9 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration a
6266
throws Exception {
6367
return authenticationConfiguration.getAuthenticationManager();
6468
}
69+
70+
@Bean
71+
public ApiKeyAuthenticationFilter apiKeyAuthenticationFilter(UserAuthenticationService userAuthenticationService) {
72+
return new ApiKeyAuthenticationFilter(userAuthenticationService);
73+
}
6574
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
package net.hackyourfuture.coursehub.data;
22

3-
public record UserAccountEntity(Integer userId, String emailAddress, String passwordHash, Role role) {}
3+
public record UserAccountEntity(Integer userId, String emailAddress, String passwordHash, Role role, String apiKey) {}

backend/src/main/java/net/hackyourfuture/coursehub/repository/UserAccountRepository.java

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ public class UserAccountRepository {
1717
rs.getInt("user_id"),
1818
rs.getString("email_address"),
1919
rs.getString("password_hash"),
20-
Role.valueOf(rs.getString("role")));
20+
Role.valueOf(rs.getString("role")),
21+
rs.getString("api_key"));
2122
private final NamedParameterJdbcTemplate jdbcTemplate;
2223

2324
public UserAccountRepository(NamedParameterJdbcTemplate jdbcTemplate) {
@@ -48,7 +49,7 @@ public UserAccountEntity insertUserAccount(String emailAddress, String passwordH
4849
}
4950
String userSql = "INSERT INTO user_account (email_address, password_hash, role) "
5051
+ "VALUES (:emailAddress, :passwordHash, :role::role) "
51-
+ "RETURNING user_id, email_address, password_hash, role";
52+
+ "RETURNING user_id, email_address, password_hash, role, api_key";
5253
return jdbcTemplate.queryForObject(
5354
userSql,
5455
Map.of(
@@ -67,4 +68,37 @@ public Integer findUserIdByEmail(String emailAddress) {
6768
return null;
6869
}
6970
}
71+
72+
/**
73+
* Stores the given API key for the user with the given ID.
74+
*
75+
* @param userId the user ID
76+
* @param apiKey the API key to store
77+
* @return the updated UserAccountEntity
78+
*/
79+
@Transactional
80+
public UserAccountEntity updateApiKey(Integer userId, String apiKey) {
81+
String sql = "UPDATE user_account SET api_key = :apiKey WHERE user_id = :userId " +
82+
"RETURNING user_id, email_address, password_hash, role, api_key";
83+
return jdbcTemplate.queryForObject(
84+
sql,
85+
Map.of("userId", userId, "apiKey", apiKey),
86+
USER_ACCOUNT_ROW_MAPPER);
87+
}
88+
89+
/**
90+
* Finds a user by API key.
91+
*
92+
* @param apiKey the API key
93+
* @return the UserAccountEntity, or null if not found
94+
*/
95+
@Nullable
96+
public UserAccountEntity findByApiKey(String apiKey) {
97+
String sql = "SELECT * FROM user_account WHERE api_key = :apiKey";
98+
try {
99+
return jdbcTemplate.queryForObject(sql, Map.of("apiKey", apiKey), USER_ACCOUNT_ROW_MAPPER);
100+
} catch (EmptyResultDataAccessException e) {
101+
return null;
102+
}
103+
}
70104
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package net.hackyourfuture.coursehub.security;
2+
3+
import jakarta.servlet.FilterChain;
4+
import jakarta.servlet.ServletException;
5+
import jakarta.servlet.http.HttpServletRequest;
6+
import jakarta.servlet.http.HttpServletResponse;
7+
import net.hackyourfuture.coursehub.data.AuthenticatedUser;
8+
import net.hackyourfuture.coursehub.service.UserAuthenticationService;
9+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
10+
import org.springframework.security.core.context.SecurityContextHolder;
11+
import org.springframework.web.filter.OncePerRequestFilter;
12+
13+
import java.io.IOException;
14+
15+
public class ApiKeyAuthenticationFilter extends OncePerRequestFilter {
16+
17+
private final UserAuthenticationService userAuthenticationService;
18+
private static final String AUTHORIZATION_HEADER_KEY = "Authorization";
19+
20+
public ApiKeyAuthenticationFilter(UserAuthenticationService userAuthenticationService) {
21+
this.userAuthenticationService = userAuthenticationService;
22+
}
23+
24+
@Override
25+
protected void doFilterInternal(
26+
HttpServletRequest request,
27+
HttpServletResponse response,
28+
FilterChain filterChain) throws ServletException, IOException {
29+
30+
// Skip API key authentication if already authenticated
31+
if (SecurityContextHolder.getContext().getAuthentication() != null &&
32+
SecurityContextHolder.getContext().getAuthentication().isAuthenticated()) {
33+
filterChain.doFilter(request, response);
34+
return;
35+
}
36+
37+
String apiKey = request.getHeader(AUTHORIZATION_HEADER_KEY);
38+
39+
if (apiKey != null && !apiKey.isEmpty()) {
40+
// Look up user by API key
41+
AuthenticatedUser user = userAuthenticationService.findUserByApiKey(apiKey);
42+
43+
if (user != null) {
44+
// Create an authentication token
45+
UsernamePasswordAuthenticationToken authentication =
46+
new UsernamePasswordAuthenticationToken(
47+
user,
48+
null, // No credentials needed as we authenticated via API key
49+
user.getAuthorities()
50+
);
51+
52+
// Set authentication in context
53+
SecurityContextHolder.getContext().setAuthentication(authentication);
54+
}
55+
}
56+
57+
filterChain.doFilter(request, response);
58+
}
59+
}

backend/src/main/java/net/hackyourfuture/coursehub/service/UserAuthenticationService.java

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
import org.springframework.security.crypto.password.PasswordEncoder;
1414
import org.springframework.stereotype.Service;
1515

16+
import java.security.NoSuchAlgorithmException;
17+
import java.security.SecureRandom;
18+
import java.util.Base64;
19+
1620
@Service
1721
public class UserAuthenticationService implements UserDetailsService {
1822
private final UserAccountRepository userAccountRepository;
@@ -69,4 +73,57 @@ public void register(String firstName, String lastName, String emailAddress, Str
6973
var passwordHash = passwordEncoder.encode(password);
7074
studentRepository.insertStudent(firstName, lastName, emailAddress, passwordHash);
7175
}
76+
77+
/**
78+
* Generates a new API key for the current authenticated user.
79+
* @return the generated API key
80+
* @throws IllegalStateException if no user is authenticated
81+
*/
82+
public String generateApiKey() {
83+
AuthenticatedUser authenticatedUser = currentAuthenticatedUser();
84+
if (authenticatedUser == null) {
85+
throw new IllegalStateException("No authenticated user found");
86+
}
87+
88+
// Generate a secure random API key with a prefix to identify it as an API key
89+
byte[] randomBytes = new byte[32];
90+
try {
91+
SecureRandom.getInstanceStrong().nextBytes(randomBytes);
92+
} catch (NoSuchAlgorithmException e) {
93+
throw new IllegalStateException("Unable to generate an API key", e);
94+
}
95+
String apiKey = "chub_" + Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
96+
97+
// Store the API key in the database
98+
userAccountRepository.updateApiKey(authenticatedUser.getUserId(), apiKey);
99+
100+
return apiKey;
101+
}
102+
103+
/**
104+
* Finds a user by their API key.
105+
* @param apiKey the API key
106+
* @return the authenticated user or null if not found
107+
*/
108+
public AuthenticatedUser findUserByApiKey(String apiKey) {
109+
UserAccountEntity userAccount = userAccountRepository.findByApiKey(apiKey);
110+
if (userAccount == null) {
111+
return null;
112+
}
113+
114+
return buildAuthenticatedUser(userAccount);
115+
}
116+
117+
private AuthenticatedUser buildAuthenticatedUser(UserAccountEntity userAccount) {
118+
return switch (userAccount.role()) {
119+
case student -> {
120+
StudentEntity student = studentRepository.findById(userAccount.userId());
121+
yield new AuthenticatedUser(userAccount.userId(), student.firstName(), student.lastName(), userAccount.emailAddress(), userAccount.passwordHash(), userAccount.role());
122+
}
123+
case instructor -> {
124+
InstructorEntity instructor = instructorRepository.findById(userAccount.userId());
125+
yield new AuthenticatedUser(userAccount.userId(), instructor.firstName(), instructor.lastName(), userAccount.emailAddress(), userAccount.passwordHash(), userAccount.role());
126+
}
127+
};
128+
}
72129
}

backend/src/main/java/net/hackyourfuture/coursehub/web/UserAuthenticationController.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import jakarta.servlet.http.HttpServletRequest;
44
import jakarta.servlet.http.HttpServletResponse;
55
import net.hackyourfuture.coursehub.service.UserAuthenticationService;
6+
import net.hackyourfuture.coursehub.web.model.ApiKeyResponse;
67
import net.hackyourfuture.coursehub.web.model.HttpErrorResponse;
78
import net.hackyourfuture.coursehub.web.model.LoginRequest;
89
import net.hackyourfuture.coursehub.web.model.LoginSuccessResponse;
@@ -75,10 +76,21 @@ public LoginSuccessResponse register(@RequestBody RegisterRequest request, HttpS
7576
request.emailAddress(),
7677
request.password()
7778
);
78-
79+
// Authenticate the user and return the response
7980
return authenticate(httpRequest, httpResponse, request.emailAddress(), request.password());
8081
}
8182

83+
@PostMapping("/generate-api-key")
84+
public ResponseEntity<Object> generateApiKey() {
85+
try {
86+
String apiKey = userAuthenticationService.generateApiKey();
87+
return ResponseEntity.ok(new ApiKeyResponse(apiKey));
88+
} catch (IllegalStateException e) {
89+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
90+
.body(new HttpErrorResponse("Unable to generate API key"));
91+
}
92+
}
93+
8294
private LoginSuccessResponse authenticate(HttpServletRequest request, HttpServletResponse response, String email, String password) {
8395
// Authenticate the user with the provided credentials (email and password)
8496
Authentication authentication = authenticationManager.authenticate(
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package net.hackyourfuture.coursehub.web.model;
2+
3+
/**
4+
* Response object for API key generation.
5+
* @param apiKey The generated API key
6+
*/
7+
public record ApiKeyResponse(String apiKey) {
8+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Add API key column to user_account table
2+
ALTER TABLE user_account ADD COLUMN api_key VARCHAR(100) NULL;
3+
CREATE UNIQUE INDEX idx_api_key_unique ON user_account (api_key) WHERE api_key IS NOT NULL;
4+

frontend/src/pages/Profile.tsx

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import React, { useState } from 'react';
2-
import { User } from '../types/User';
3-
import { Navigate } from 'react-router';
1+
import React, {useState} from 'react';
2+
import {User} from '../types/User';
3+
import {Navigate} from 'react-router';
4+
import {useConfig} from '../ConfigContext';
45

56
function Profile({ user }: { user: User | null }) {
67
const [apiKey, setApiKey] = useState<string | null>(null);
78
const [isGenerating, setIsGenerating] = useState(false);
89
const [error, setError] = useState<string | null>(null);
10+
const {backendUrl} = useConfig();
911

1012
if (!user) {
1113
return <Navigate to="/login" />;
@@ -16,27 +18,20 @@ function Profile({ user }: { user: User | null }) {
1618
setError(null);
1719

1820
try {
19-
// TODO: Replace with actual backend endpoint when implemented
20-
// This is a placeholder that simulates API key generation
21-
setTimeout(() => {
22-
// Mock API key (in production this would come from the backend)
23-
const mockApiKey = `key_${Math.random().toString(36).substring(2, 15)}`;
24-
setApiKey(mockApiKey);
25-
setIsGenerating(false);
26-
}, 1000);
21+
const response = await fetch(`${backendUrl}/generate-api-key`, {
22+
method: 'POST',
23+
credentials: 'include',
24+
});
2725

28-
// Actual API call would look like:
29-
// const response = await fetch('http://localhost:8080/api/users/generate-api-key', {
30-
// method: 'POST',
31-
// credentials: 'include',
32-
// headers: {
33-
// 'Content-Type': 'application/json',
34-
// }
35-
// });
36-
// const data = await response.json();
37-
// setApiKey(data.apiKey);
26+
if (!response.ok) {
27+
throw new Error('Failed to generate API key');
28+
}
29+
30+
const data = await response.json();
31+
setApiKey(data.apiKey);
3832
} catch (err) {
3933
setError('Failed to generate API key. Please try again later.');
34+
} finally {
4035
setIsGenerating(false);
4136
}
4237
};

0 commit comments

Comments
 (0)