/*
* Copyright (C) 2008-2024, Juick
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
package com.juick.config;
import com.juick.ActivityPubManager;
import com.juick.KeystoreManager;
import com.juick.service.UserService;
import com.juick.service.security.HTTPSignatureAuthenticationFilter;
import com.juick.service.security.HashParamAuthenticationFilter;
import com.juick.service.security.JuickUserDetailsService;
import com.juick.service.security.entities.JuickUser;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.config.Customizer;
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.RememberMeServices;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices;
import org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import jakarta.inject.Inject;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.Arrays;
import java.util.Collections;
import static org.springframework.security.config.Customizer.withDefaults;
/**
* Created by aalexeev on 11/21/16.
*/
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Inject
private UserService userService;
@Inject
private KeystoreManager keystoreManager;
@Inject
private JdbcTemplate jdbcTemplate;
private static final String COOKIE_NAME = "juick-remember-me";
@Bean
UserDetailsService userDetailsService() {
return new JuickUserDetailsService(userService);
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Collections.singletonList("*"));
configuration.setAllowedMethods(Arrays.asList("POST", "GET", "PUT", "OPTIONS", "DELETE"));
configuration.setAllowedHeaders(Collections.singletonList("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/oauth/**", configuration);
source.registerCorsConfiguration("/api/**", configuration);
source.registerCorsConfiguration("/u/**", configuration);
source.registerCorsConfiguration("/n/**", configuration);
return source;
}
@Inject
private ActivityPubManager activityPubManager;
@Bean
HashParamAuthenticationFilter apiAuthenticationFilter() {
return new HashParamAuthenticationFilter(userService, null);
}
@Bean
AuthenticationEntryPoint apiAuthenticationEntryPoint() {
var entryPoint = new BasicAuthenticationEntryPoint();
entryPoint.setRealmName("Juick");
return entryPoint;
}
@Value("${auth_remember_me_key:secret}")
private String rememberMeKey;
@Value("${web_domain:localhost}")
private String webDomain;
@Bean
HashParamAuthenticationFilter wwwAuthenticationFilter() {
return new HashParamAuthenticationFilter(userService, hashCookieServices());
}
@Bean
RememberMeServices hashCookieServices() {
TokenBasedRememberMeServices services = new TokenBasedRememberMeServices(
rememberMeKey, userDetailsService());
services.setCookieName(COOKIE_NAME);
services.setCookieDomain(webDomain);
services.setAlwaysRemember(true);
services.setTokenValiditySeconds(6 * 30 * 24 * 3600);
services.setUseSecureCookie(false); // TODO set true if https is supports
return services;
}
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults());
http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// Accept access tokens for User Info and/or Client Registration
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()));
return http.formLogin(Customizer.withDefaults()).build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
return new JdbcRegisteredClientRepository(jdbcTemplate);
}
@Bean
public JWKSource jwkSource() {
RSAPublicKey publicKey = (RSAPublicKey) keystoreManager.getPublicKey();
RSAPrivateKey privateKey = (RSAPrivateKey) keystoreManager.getPrivateKey();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(webDomain)
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}
@Bean
public JwtDecoder jwtDecoder(JWKSource jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.authorizationEndpoint("/oauth/authorize")
.tokenEndpoint("/oauth/token")
.build();
}
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**", "/u/**", "/n/**")
.addFilterBefore(apiAuthenticationFilter(), BasicAuthenticationFilter.class)
.addFilterBefore(new HTTPSignatureAuthenticationFilter(activityPubManager, userService),
BasicAuthenticationFilter.class)
.authorizeHttpRequests(requests -> requests
.requestMatchers(HttpMethod.OPTIONS).permitAll()
.requestMatchers("/api/", "/api/messages", "/api/avatar",
"/v3/api-docs",
"/sw.js",
"/api/swagger-ui/**",
"/api/messages/discussions",
"/api/users", "/api/thread", "/api/tags",
"/api/tlgmbtwbhk", "/api/fbwbhk", "/api/_patreon", "/api/_vk",
"/api/skypebotendpoint", "/api/signup",
"/api/inbox", "/api/events", "/api/u/", "/u/**",
"/n/**",
"/api/info/**", "/api/v1/apps", "/api/v1/instance",
"/api/nodeinfo/2.0", "/oauth/**")
.permitAll()
.anyRequest().hasAnyAuthority("SCOPE_write", "ROLE_USER"))
.anonymous(anonymous -> anonymous.principal(JuickUser.ANONYMOUS_USER)
.authorities(JuickUser.ANONYMOUS_AUTHORITY))
.httpBasic(httpBasic -> httpBasic
.authenticationEntryPoint(apiAuthenticationEntryPoint()))
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()))
.sessionManagement(sessionManagement -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(apiAuthenticationEntryPoint()))
.csrf(AbstractHttpConfigurer::disable)
.rememberMe(rememberMe -> rememberMe
.rememberMeCookieDomain(webDomain).key(rememberMeKey)
.rememberMeServices(hashCookieServices()))
.headers(headers -> headers.defaultsDisabled().cacheControl(withDefaults()));
return http.build();
}
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
SecurityFilterChain h2ConsoleFilterChain(HttpSecurity http) throws Exception {
http.securityMatcher("/h2-console/**")
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll())
.anonymous(anonymous -> anonymous.principal(JuickUser.ANONYMOUS_USER)
.authorities(JuickUser.ANONYMOUS_AUTHORITY))
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagement -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(apiAuthenticationEntryPoint()))
.headers(headers -> headers.defaultsDisabled().cacheControl(withDefaults()));
return http.build();
}
@Bean
AuthenticationSuccessHandler successHandler() {
var handler = new SavedRequestAwareAuthenticationSuccessHandler();
handler.setUseReferer(true);
return handler;
}
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE + 2)
SecurityFilterChain wwwChain(HttpSecurity http) throws Exception {
http.addFilterBefore(wwwAuthenticationFilter(), BasicAuthenticationFilter.class)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/settings", "/pm/**", "/**/bl", "/_twitter", "/post",
"/comment")
.authenticated()
.anyRequest().permitAll())
.anonymous(anonymous -> anonymous.principal(JuickUser.ANONYMOUS_USER)
.authorities(JuickUser.ANONYMOUS_AUTHORITY))
.cors(cors -> cors
.configurationSource(corsConfigurationSource()))
.logout(logout -> logout
.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.invalidateHttpSession(true)
.clearAuthentication(true)
.logoutSuccessUrl("/login")
.addLogoutHandler((request, response, authentication) -> {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null) {
var principal = auth.getPrincipal();
if (principal instanceof JuickUser) {
var user = ((JuickUser) principal).getUser();
userService.logout(user.getUid());
}
}
})
.deleteCookies("hash", COOKIE_NAME))
.formLogin(form -> form.loginPage("/login")
.usernameParameter("username")
.passwordParameter("password")
.successHandler(successHandler())
.failureUrl("/login?error=1")
.permitAll())
.csrf(AbstractHttpConfigurer::disable)
.rememberMe(rememberMe -> rememberMe
.rememberMeCookieDomain(webDomain).key(rememberMeKey)
.rememberMeServices(hashCookieServices()))
.headers(headers -> headers.defaultsDisabled().cacheControl(withDefaults()));
return http.build();
}
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
public SecurityFilterChain securityWebFilterChain(
HttpSecurity http) throws Exception {
return http.securityMatcher("/actuator/**")
.authorizeHttpRequests(authorize -> authorize.anyRequest().hasRole("ADMIN")).build();
}
}