Implement token-based authentication

This commit is contained in:
William Brawner 2021-01-18 17:20:30 -07:00
parent 7237e12363
commit 5a9c845b3f
15 changed files with 280 additions and 12 deletions

View file

@ -27,7 +27,7 @@ jar {
description = "twigs-server"
}
mainClassName = "com.wbrawner.budgetserver.BudgetServerApplication"
mainClassName = "com.wbrawner.budgetserver.TwigsServerApplication"
sourceCompatibility = 14
targetCompatibility = 14

View file

@ -2,10 +2,12 @@ package com.wbrawner.budgetserver;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
public class BudgetServerApplication {
@EnableScheduling
public class TwigsServerApplication {
public static void main(String[] args) {
SpringApplication.run(BudgetServerApplication.class, args);
SpringApplication.run(TwigsServerApplication.class, args);
}
}

View file

@ -3,10 +3,11 @@ package com.wbrawner.budgetserver;
import com.wbrawner.budgetserver.user.User;
import org.springframework.security.core.context.SecurityContextHolder;
import java.security.SecureRandom;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.UUID;
import java.util.Random;
public final class Utils {
private static final int[] CALENDAR_FIELDS = new int[]{
@ -33,6 +34,12 @@ public final class Utils {
return calendar.getTime();
}
public static Date twoWeeksFromNow() {
GregorianCalendar calendar = new GregorianCalendar();
calendar.add(Calendar.DATE, 14);
return calendar.getTime();
}
public static User getCurrentUser() {
Object user = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (user instanceof User) {
@ -42,7 +49,18 @@ public final class Utils {
return null;
}
private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private static final Random random = new SecureRandom();
public static String randomString(int length) {
StringBuilder id = new StringBuilder();
for (int i = 0; i < length; i++) {
id.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length())));
}
return id.toString();
}
public static String randomId() {
return UUID.randomUUID().toString().replace("-", "");
return randomString(32);
}
}

View file

@ -2,5 +2,5 @@ package com.wbrawner.budgetserver.budget;
import org.springframework.data.repository.PagingAndSortingRepository;
public interface BudgetRepository extends PagingAndSortingRepository<Budget, Long> {
public interface BudgetRepository extends PagingAndSortingRepository<Budget, String> {
}

View file

@ -1,6 +1,7 @@
package com.wbrawner.budgetserver.config;
import com.wbrawner.budgetserver.passwordresetrequest.PasswordResetRequestRepository;
import com.wbrawner.budgetserver.session.UserSessionRepository;
import com.wbrawner.budgetserver.user.UserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -11,6 +12,7 @@ import org.springframework.security.config.annotation.authentication.builders.Au
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
@ -28,6 +30,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final Environment env;
private final DataSource datasource;
private final UserSessionRepository userSessionRepository;
private final UserRepository userRepository;
private final PasswordResetRequestRepository passwordResetRequestRepository;
private final JdbcUserDetailsService userDetailsService;
@ -35,12 +38,14 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
public SecurityConfig(Environment env,
DataSource datasource,
UserSessionRepository userSessionRepository,
UserRepository userRepository,
PasswordResetRequestRepository passwordResetRequestRepository,
JdbcUserDetailsService userDetailsService,
Environment environment) {
this.env = env;
this.datasource = datasource;
this.userSessionRepository = userSessionRepository;
this.userRepository = userRepository;
this.passwordResetRequestRepository = passwordResetRequestRepository;
this.userDetailsService = userDetailsService;
@ -56,7 +61,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public DaoAuthenticationProvider getAuthenticationProvider() {
var authProvider = new DaoAuthenticationProvider();
var authProvider = new TokenAuthenticationProvider(userSessionRepository, userRepository);
authProvider.setPasswordEncoder(getPasswordEncoder());
authProvider.setUserDetailsService(userDetailsService);
return authProvider;
@ -81,6 +86,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
.authenticated()
.and()
.httpBasic()
.authenticationEntryPoint(new SilentAuthenticationEntryPoint())
.and()
.cors()
.configurationSource(request -> {
@ -104,7 +110,9 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
})
.and()
.csrf()
.disable();
.disable()
.addFilter(new TokenAuthenticationFilter(authenticationManager()))
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}

View file

@ -0,0 +1,20 @@
package com.wbrawner.budgetserver.config;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
public class SessionAuthenticationToken extends UsernamePasswordAuthenticationToken {
/**
* Creates a token with the supplied array of authorities.
*
* @param authorities the collection of <tt>GrantedAuthority</tt>s for the principal
* represented by this authentication object.
* @param credentials
* @param principal
*/
public SessionAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(principal, credentials, authorities);
}
}

View file

@ -0,0 +1,19 @@
package com.wbrawner.budgetserver.config;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* Used to avoid browser prompts for authentication
*/
public class SilentAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
}
}

View file

@ -0,0 +1,32 @@
package com.wbrawner.budgetserver.config;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
public class TokenAuthenticationFilter extends BasicAuthenticationFilter {
public TokenAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
var authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
var token = authHeader.substring(7);
var authentication = getAuthenticationManager().authenticate(new SessionAuthenticationToken(null, token, Collections.emptyList()));
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}

View file

@ -0,0 +1,48 @@
package com.wbrawner.budgetserver.config;
import com.wbrawner.budgetserver.session.UserSessionRepository;
import com.wbrawner.budgetserver.user.UserRepository;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Date;
public class TokenAuthenticationProvider extends DaoAuthenticationProvider {
private final UserSessionRepository userSessionRepository;
private final UserRepository userRepository;
public TokenAuthenticationProvider(UserSessionRepository userSessionRepository, UserRepository userRepository) {
this.userSessionRepository = userSessionRepository;
this.userRepository = userRepository;
}
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (!(authentication instanceof SessionAuthenticationToken)) {
// Additional checks aren't needed since they've already been handled
super.additionalAuthenticationChecks(userDetails, authentication);
}
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (authentication instanceof SessionAuthenticationToken) {
var session = userSessionRepository.findByToken((String) authentication.getCredentials());
if (session.isEmpty() || session.get().getExpiration().before(new Date())) {
throw new BadCredentialsException("Credentials expired");
}
var user = userRepository.findById(session.get().getUserId());
if (user.isEmpty()) {
throw new InternalAuthenticationServiceException("Failed to find user for token");
}
return new SessionAuthenticationToken(user.get(), authentication.getCredentials(), authentication.getAuthorities());
} else {
return super.authenticate(authentication);
}
}
}

View file

@ -0,0 +1,44 @@
package com.wbrawner.budgetserver.session;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.util.Date;
import static com.wbrawner.budgetserver.Utils.*;
@Entity
public class Session {
@Id
private final String id = randomId();
private final String userId;
private final String token = randomString(255);
private Date expiration = twoWeeksFromNow();
public Session() {
this("");
}
public Session(String userId) {
this.userId = userId;
}
public String getId() {
return id;
}
public String getUserId() {
return userId;
}
public String getToken() {
return token;
}
public Date getExpiration() {
return expiration;
}
public void setExpiration(Date expiration) {
this.expiration = expiration;
}
}

View file

@ -0,0 +1,22 @@
package com.wbrawner.budgetserver.session;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class SessionCleanupTask {
private final UserSessionRepository sessionRepository;
@Autowired
public SessionCleanupTask(UserSessionRepository sessionRepository) {
this.sessionRepository = sessionRepository;
}
@Scheduled(cron = "0 0 * * * *")
public void cleanup() {
sessionRepository.deleteAllByExpirationBefore(new Date());
}
}

View file

@ -0,0 +1,25 @@
package com.wbrawner.budgetserver.session;
import java.util.Date;
public class SessionResponse {
private final String token;
private final String expiration;
public SessionResponse(Session session) {
this(session.getToken(), session.getExpiration());
}
public SessionResponse(String token, Date expiration) {
this.token = token;
this.expiration = expiration.toInstant().toString();
}
public String getToken() {
return token;
}
public String getExpiration() {
return expiration;
}
}

View file

@ -0,0 +1,17 @@
package com.wbrawner.budgetserver.session;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.Date;
import java.util.List;
import java.util.Optional;
public interface UserSessionRepository extends PagingAndSortingRepository<Session, String> {
List<Session> findByUserId(String userId);
Optional<Session> findByToken(String token);
Optional<Session> findByUserIdAndToken(String userId, String token);
void deleteAllByExpirationBefore(Date expiration);
}

View file

@ -1,10 +1,13 @@
package com.wbrawner.budgetserver.user;
import org.springframework.lang.NonNull;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import javax.persistence.*;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Transient;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
@ -31,6 +34,7 @@ public class User implements UserDetails {
this.email = email;
}
@NonNull
public String getId() {
// This shouldn't ever need to be set manually, only through Hibernate
//noinspection ConstantConditions

View file

@ -4,6 +4,9 @@ import com.wbrawner.budgetserver.ErrorResponse;
import com.wbrawner.budgetserver.budget.BudgetRepository;
import com.wbrawner.budgetserver.permission.UserPermissionRepository;
import com.wbrawner.budgetserver.permission.UserPermissionResponse;
import com.wbrawner.budgetserver.session.Session;
import com.wbrawner.budgetserver.session.SessionResponse;
import com.wbrawner.budgetserver.session.UserSessionRepository;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.Authorization;
@ -20,6 +23,7 @@ import org.springframework.web.bind.annotation.*;
import javax.transaction.Transactional;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import static com.wbrawner.budgetserver.Utils.getCurrentUser;
@ -33,16 +37,19 @@ public class UserController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final UserPermissionRepository userPermissionsRepository;
private final UserSessionRepository userSessionRepository;
private final DaoAuthenticationProvider authenticationProvider;
@Autowired
public UserController(BudgetRepository budgetRepository,
UserRepository userRepository,
UserSessionRepository userSessionRepository,
PasswordEncoder passwordEncoder,
UserPermissionRepository userPermissionsRepository,
DaoAuthenticationProvider authenticationProvider) {
this.budgetRepository = budgetRepository;
this.userRepository = userRepository;
this.userSessionRepository = userSessionRepository;
this.passwordEncoder = passwordEncoder;
this.userPermissionsRepository = userPermissionsRepository;
this.authenticationProvider = authenticationProvider;
@ -51,7 +58,7 @@ public class UserController {
@GetMapping(path = "", produces = {MediaType.APPLICATION_JSON_VALUE})
@ApiOperation(value = "getUsers", nickname = "getUsers", tags = {"Users"})
ResponseEntity<List<UserPermissionResponse>> getUsers(Long budgetId) {
ResponseEntity<List<UserPermissionResponse>> getUsers(String budgetId) {
var budget = budgetRepository.findById(budgetId).orElse(null);
if (budget == null) {
return ResponseEntity.notFound().build();
@ -69,7 +76,7 @@ public class UserController {
@PostMapping(path = "/login", produces = {MediaType.APPLICATION_JSON_VALUE})
@ApiOperation(value = "login", nickname = "login", tags = {"Users"})
ResponseEntity<UserResponse> login(@RequestBody LoginRequest request) {
ResponseEntity<SessionResponse> login(@RequestBody LoginRequest request) {
var authReq = new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword());
Authentication auth;
try {
@ -78,7 +85,9 @@ public class UserController {
return ResponseEntity.notFound().build();
}
SecurityContextHolder.getContext().setAuthentication(auth);
return ResponseEntity.ok(new UserResponse(getCurrentUser()));
var user = Objects.requireNonNull(getCurrentUser());
var session = userSessionRepository.save(new Session(user.getId()));
return ResponseEntity.ok(new SessionResponse(session));
}
@GetMapping(path = "/me", produces = {MediaType.APPLICATION_JSON_VALUE})