/* * 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(); } }