I'm trying to create REST API and web/MVC application in Spring. They both should use the same service layer. Can I somehow use two completely different configurations in Spring (Token authentication for API, cookies for web, 404 page for web, etc)? Or should I make two independent Spring applications?
问题:
回答1:
Spring-MVC
and Spring-Security
Spring-MVC configuration by default facilitates
Controller can return ModelAndView for Web application view serving purpose.
Controller can be used as
RestController
where response is by default processed byHttpMessageConverters
where controller methods used asRest-API
However we can use Spring-Security
which is a filter based framework and it acts as a
security-wall(http-firewall) between your Rest-APIs and client-app consuming Rest API
Or
security-wall(http-firewall) between Spring-MVC
application and end-user
If requirement is
- Secure web application
- Login form for authenticating first time.
- Session for subsequent requests authentication.
- Hence Every requests will have state i.e, stateful requests
- Secure Rest API(Token based authentication)
- Every requests will be stateless
- Token based authentication should be preferred
- Session will not work in case if request is from cross-origin(different origin)
then Implementation considerations
Implementation-type 1. Rest APIs should only accessed if auth token is present and valid.
- Limitation of this implementation type is, if web application wants to make AJAX calls to Rest API even though browser has valid session it won't allow to access Web-APIs.
- Here Rest API is only for stateless access.
Implementation-type 2. Rest APIs can be accessed by auth token as well as session.
- Here Rest API's can be accessed by any third party applications(cross-origin) by auth token.
- Here Rest API's can be accessed in web application(same-origin) through AJAX calls.
Implementation-type 1
- It has multiple http security configuration(two http security configuration)
- where http configuration of @order(1) will authorize only
"/api/**"
rest of url's will not be considered by this configuration. This http configuration will be configured for stateless. And you should configure an implementation ofOncePerRequestFilter
(SayJwtAuthFilter
) and filter order can be beforeUsernamePasswordAuthenticationFilter
orBasicAuthenticationFilter
. But your filter should read the header for auth token, validate it and should createAuthentication
object and set it toSecurityContext
without fail. - And http configuration of @order(2) will authorize if request is not qualified for first order http configuration. And this configuration does not configures
JwtAuthFilter
but configuresUsernamePasswordAuthenticationFilter
(.formLogin()
does this for you)
@Configuration
@EnableWebSecurity
@ComponentScan(basePackages = "com.gmail.nlpraveennl")
public class SpringSecurityConfig
{
@Bean
public PasswordEncoder passwordEncoder()
{
return new BCryptPasswordEncoder();
}
@Configuration
@Order(1)
public static class RestApiSecurityConfig extends WebSecurityConfigurerAdapter
{
@Autowired
private JwtAuthenticationTokenFilter jwtauthFilter;
@Override
protected void configure(HttpSecurity http) throws Exception
{
http
.csrf().disable()
.antMatcher("/api/**")
.authorizeRequests()
.antMatchers("/api/authenticate").permitAll()
.antMatchers("/api/**").hasAnyRole("APIUSER")
.and()
.addFilterBefore(jwtauthFilter, UsernamePasswordAuthenticationFilter.class);
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
@Configuration
@Order(2)
public static class LoginFormSecurityConfig extends WebSecurityConfigurerAdapter
{
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
public void configureInMemoryAuthentication(AuthenticationManagerBuilder auth) throws Exception
{
auth.inMemoryAuthentication().withUser("admin").password(passwordEncoder.encode("admin@123#")).roles("ADMIN");
}
@Override
protected void configure(HttpSecurity http) throws Exception
{
http
.csrf().disable()
.antMatcher("/**").authorizeRequests()
.antMatchers("/resources/**").permitAll()
.antMatchers("/**").hasRole("ADMIN")
.and().formLogin();
http.sessionManagement().maximumSessions(1).expiredUrl("/login?expired=true");
}
}
}
Implementation-type 2
- It has only one http security configuration
- where http configuration will authorize all
"/**"
- Here this http configuration is configured for both
UsernamePasswordAuthenticationFilter
andJwtAuthFilter
butJwtAuthFilter
should be configured beforeUsernamePasswordAuthenticationFilter
. - Trick used here is if there is no Authorization header filter chain just continues to
UsernamePasswordAuthenticationFilter
and attemptAuthentication method ofUsernamePasswordAuthenticationFilter
will get invoked if there is no valid auth object inSecurityContext
. IfJwtAuthFilter
validates token and sets auth object toSecurityContext
then even if filter chain reachesUsernamePasswordAuthenticationFilter
attemptAuthentication method will not be invoked as there is already an authentication object set inSecurityContext
.
@Configuration
@EnableWebSecurity
@ComponentScan(basePackages = "com.gmail.nlpraveennl")
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter
{
@Autowired
private JwtAuthenticationTokenFilter jwtauthFilter;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
public void configureInMemoryAuthentication(AuthenticationManagerBuilder auth) throws Exception
{
auth.inMemoryAuthentication().withUser("admin").password(passwordEncoder.encode("admin@123#")).roles("ADMIN");
}
@Override
protected void configure(HttpSecurity http) throws Exception
{
http
.csrf().disable()
.antMatcher("/**").authorizeRequests()
.antMatchers("/resources/**").permitAll()
.antMatchers("/api/authenticate").permitAll()
.antMatchers("/api/**").hasAnyRole("APIUSER","ADMIN")
.antMatchers("/**").hasRole("ADMIN")
.and()
.formLogin()
.and()
.addFilterBefore(jwtauthFilter, UsernamePasswordAuthenticationFilter.class);
http.sessionManagement().maximumSessions(1).expiredUrl("/login?expired=true");
}
@Bean
public PasswordEncoder passwordEncoder()
{
return new BCryptPasswordEncoder();
}
}
This is all about both type of implementation, you can go for any type of implementation depending upon your requirement. And for both implementation type JwtAuthenticationTokenFilter
and JwtTokenUtil
is common and is given below.
JwtAuthenticationTokenFilter
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException
{
final String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer "))
{
String authToken = header.substring(7);
try
{
String username = jwtTokenUtil.getUsernameFromToken(authToken);
if (username != null)
{
if (jwtTokenUtil.validateToken(authToken, username))
{
// here username should be validated with database and get authorities from database if valid
// Say just to hard code
List<GrantedAuthority> authList = new ArrayList<>();
authList.add(new SimpleGrantedAuthority("ROLE_APIUSER"));
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, null, authList);
usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
else
{
System.out.println("Token has been expired");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
}
}
catch (Exception e)
{
System.out.println("Unable to get JWT Token, possibly expired");
response.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
}
chain.doFilter(request, response);
}
}
JwtTokenUtil
@Component
public class JwtTokenUtil implements Serializable
{
private static final long serialVersionUID = 8544329907338151549L;
// public static final long JWT_TOKEN_VALIDITY = 5 * 60 * 60 * 1000; // 5 Hours
public static final long JWT_TOKEN_VALIDITY = 5 * 60 * 1000; // 5 Minutes
private String secret = "my-secret";
public String getUsernameFromToken(String token)
{
return getClaimFromToken(token, Claims::getSubject);
}
public Date getExpirationDateFromToken(String token)
{
return getClaimFromToken(token, Claims::getExpiration);
}
public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver)
{
final Claims claims = getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}
private Claims getAllClaimsFromToken(String token)
{
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
private Boolean isTokenExpired(String token)
{
final Date expiration = getExpirationDateFromToken(token);
return expiration.before(new Date());
}
public String generateToken(String username)
{
Map<String, Object> claims = new HashMap<>();
return doGenerateToken(claims, username);
}
private String doGenerateToken(Map<String, Object> claims, String subject)
{
return "Bearer "+Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY)).signWith(SignatureAlgorithm.HS512, secret).compact();
}
public Boolean validateToken(String token, String usernameFromToken)
{
final String username = getUsernameFromToken(token);
return (username.equals(usernameFromToken) && !isTokenExpired(token));
}
}
You can download working example from my github repository link given below.
Implementation type-1
Implementation type-2
If you are curious about sequence of execution in Spring Security you can refer my answer here -> How spring security filter chain works
回答2:
You can write a rest controller and normal controller for all endpoints. Spring security will automatically add an auth flow when you add it, and if you want to override you can do that in the configuration.
Rest Controller for /api/foo
@RestController
@RequestMapping("/api/foo")
public class FooRestController {
//All the methods must conform to a rest api
@GetMapping
public String fooGet() {
return "foo"; // this will return foo as string
}
}
Normal controller for /ui/foo
@Controller
@RequestMapping("/ui/foo")
public class FooController {
@RequestMapping(method = RequestMethod.GET) // You can use @GetMapping
public ModelView homePage(Model model) {
// set model attributes
return "home"; // this will be mapped to home view jsp/thyme/html
}
}
This way you can separate cookie logic and manage redirects and validations, in the normal controller.