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" description = "twigs-server"
} }
mainClassName = "com.wbrawner.budgetserver.BudgetServerApplication" mainClassName = "com.wbrawner.budgetserver.TwigsServerApplication"
sourceCompatibility = 14 sourceCompatibility = 14
targetCompatibility = 14 targetCompatibility = 14

View file

@ -2,10 +2,12 @@ package com.wbrawner.budgetserver;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
public class BudgetServerApplication { @EnableScheduling
public class TwigsServerApplication {
public static void main(String[] args) { 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 com.wbrawner.budgetserver.user.User;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import java.security.SecureRandom;
import java.util.Calendar; import java.util.Calendar;
import java.util.Date; import java.util.Date;
import java.util.GregorianCalendar; import java.util.GregorianCalendar;
import java.util.UUID; import java.util.Random;
public final class Utils { public final class Utils {
private static final int[] CALENDAR_FIELDS = new int[]{ private static final int[] CALENDAR_FIELDS = new int[]{
@ -33,6 +34,12 @@ public final class Utils {
return calendar.getTime(); return calendar.getTime();
} }
public static Date twoWeeksFromNow() {
GregorianCalendar calendar = new GregorianCalendar();
calendar.add(Calendar.DATE, 14);
return calendar.getTime();
}
public static User getCurrentUser() { public static User getCurrentUser() {
Object user = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); Object user = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (user instanceof User) { if (user instanceof User) {
@ -42,7 +49,18 @@ public final class Utils {
return null; 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() { 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; 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; package com.wbrawner.budgetserver.config;
import com.wbrawner.budgetserver.passwordresetrequest.PasswordResetRequestRepository; import com.wbrawner.budgetserver.passwordresetrequest.PasswordResetRequestRepository;
import com.wbrawner.budgetserver.session.UserSessionRepository;
import com.wbrawner.budgetserver.user.UserRepository; import com.wbrawner.budgetserver.user.UserRepository;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; 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.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.JdbcUserDetailsManager; import org.springframework.security.provisioning.JdbcUserDetailsManager;
@ -28,6 +30,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final Environment env; private final Environment env;
private final DataSource datasource; private final DataSource datasource;
private final UserSessionRepository userSessionRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final PasswordResetRequestRepository passwordResetRequestRepository; private final PasswordResetRequestRepository passwordResetRequestRepository;
private final JdbcUserDetailsService userDetailsService; private final JdbcUserDetailsService userDetailsService;
@ -35,12 +38,14 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
public SecurityConfig(Environment env, public SecurityConfig(Environment env,
DataSource datasource, DataSource datasource,
UserSessionRepository userSessionRepository,
UserRepository userRepository, UserRepository userRepository,
PasswordResetRequestRepository passwordResetRequestRepository, PasswordResetRequestRepository passwordResetRequestRepository,
JdbcUserDetailsService userDetailsService, JdbcUserDetailsService userDetailsService,
Environment environment) { Environment environment) {
this.env = env; this.env = env;
this.datasource = datasource; this.datasource = datasource;
this.userSessionRepository = userSessionRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.passwordResetRequestRepository = passwordResetRequestRepository; this.passwordResetRequestRepository = passwordResetRequestRepository;
this.userDetailsService = userDetailsService; this.userDetailsService = userDetailsService;
@ -56,7 +61,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean @Bean
public DaoAuthenticationProvider getAuthenticationProvider() { public DaoAuthenticationProvider getAuthenticationProvider() {
var authProvider = new DaoAuthenticationProvider(); var authProvider = new TokenAuthenticationProvider(userSessionRepository, userRepository);
authProvider.setPasswordEncoder(getPasswordEncoder()); authProvider.setPasswordEncoder(getPasswordEncoder());
authProvider.setUserDetailsService(userDetailsService); authProvider.setUserDetailsService(userDetailsService);
return authProvider; return authProvider;
@ -81,6 +86,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
.authenticated() .authenticated()
.and() .and()
.httpBasic() .httpBasic()
.authenticationEntryPoint(new SilentAuthenticationEntryPoint())
.and() .and()
.cors() .cors()
.configurationSource(request -> { .configurationSource(request -> {
@ -104,7 +110,9 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
}) })
.and() .and()
.csrf() .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; package com.wbrawner.budgetserver.user;
import org.springframework.lang.NonNull;
import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails; 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.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@ -31,6 +34,7 @@ public class User implements UserDetails {
this.email = email; this.email = email;
} }
@NonNull
public String getId() { public String getId() {
// This shouldn't ever need to be set manually, only through Hibernate // This shouldn't ever need to be set manually, only through Hibernate
//noinspection ConstantConditions //noinspection ConstantConditions

View file

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