diff --git a/.env.example b/.env.example index 7d241eb5..65a1121f 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,7 @@ APP_SERVER_HOST= APP_SERVER_PORT= APP_VERSION= APP_REPOSITORY_URL= +APP_MASTER_KEY= # Front end links FRONTEND_BASE_URL= diff --git a/src/main/java/dev/kons/kuenyawz/configurations/ApplicationProperties.java b/src/main/java/dev/kons/kuenyawz/configurations/ApplicationProperties.java index ccd2c879..3fa9e721 100644 --- a/src/main/java/dev/kons/kuenyawz/configurations/ApplicationProperties.java +++ b/src/main/java/dev/kons/kuenyawz/configurations/ApplicationProperties.java @@ -27,21 +27,24 @@ public class ApplicationProperties { // Fields - private String version; - private String repositoryUrl; - private String productImagesDir = "product-images"; private String baseUrl = "http://localhost:8081"; - private Integer maxVariantQuantity = 250; + private Boolean isContainerized = false; private String httpProtocol = "http"; private String publicIp = "localhost"; + @Value("${server.port:8081}") + private String serverPort; + private String version; + private String repositoryUrl; + private String masterKey; + + private String productImagesDir = "product-images"; + private Integer maxVariantQuantity = 250; private String timezone = "Asia/Jakarta"; @Value("${application.otp-format:NUMERIC}") private OTPService.OTPType otpFormat = OTPService.OTPType.NUMERIC; - @Value("${server.port:8081}") - private String serverPort; @Value("#{'${application.accepted-image-extensions}'.split(',')}") private List acceptedImageExtensions; @@ -60,9 +63,15 @@ public void initialize(Dotenv dotenv) { this.httpProtocol = getEnv("APP_HTTP_PROTOCOL", "http", dotenv); this.publicIp = getEnv("APP_SERVER_HOST", "localhost", dotenv); this.serverPort = getEnv("APP_SERVER_PORT", "8081", dotenv); - this.version = getEnv("APP_VERSION", "0.0", dotenv); this.repositoryUrl = getEnv("APP_REPOSITORY_URL", "https://github.com/vianneynara/*", dotenv); + this.masterKey = getEnv("APP_MASTER_KEY", null, dotenv); + + // Proper warn, currently used for AccountController + if (this.masterKey == null) + log.warn("Master key is not set. Endpoints that requires master key may not be able to be accessed."); + else + log.info("Master key is set. Keep it secret!"); this.frontend.baseUrl = getEnv("FRONTEND_BASE_URL", "http://localhost:5173", dotenv); diff --git a/src/main/java/dev/kons/kuenyawz/configurations/OpenAPIConfig.java b/src/main/java/dev/kons/kuenyawz/configurations/OpenAPIConfig.java index 8d1b29c7..43218077 100644 --- a/src/main/java/dev/kons/kuenyawz/configurations/OpenAPIConfig.java +++ b/src/main/java/dev/kons/kuenyawz/configurations/OpenAPIConfig.java @@ -27,7 +27,12 @@ public OpenAPI customOpenAPI() { .type(SecurityScheme.Type.APIKEY) .in(SecurityScheme.In.COOKIE) .name("refreshToken") - .description("HTTP-only refresh token cookie"))) + .description("HTTP-only refresh token cookie")) + .addSecuritySchemes("xApiKey", + new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .name("X-Api-Key"))) .info(new Info() .title("KuenyaWZ API") .version("1.0.0") diff --git a/src/main/java/dev/kons/kuenyawz/configurations/security/JWTAuthenticationFilter.java b/src/main/java/dev/kons/kuenyawz/configurations/security/JWTAuthenticationFilter.java index 71f2e546..49a83866 100644 --- a/src/main/java/dev/kons/kuenyawz/configurations/security/JWTAuthenticationFilter.java +++ b/src/main/java/dev/kons/kuenyawz/configurations/security/JWTAuthenticationFilter.java @@ -99,4 +99,9 @@ public static String extractRefreshTokenFromCookie(HttpServletRequest request) { } return null; } + + // May not be used yet + public static String extractApiKeyFromHeader(HttpServletRequest request) { + return request.getHeader("X-Api-Key"); + } } diff --git a/src/main/java/dev/kons/kuenyawz/configurations/security/SecurityConfig.java b/src/main/java/dev/kons/kuenyawz/configurations/security/SecurityConfig.java index f7501cd1..84bdc4f3 100644 --- a/src/main/java/dev/kons/kuenyawz/configurations/security/SecurityConfig.java +++ b/src/main/java/dev/kons/kuenyawz/configurations/security/SecurityConfig.java @@ -116,10 +116,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity httpSec) throws Exce .requestMatchers(HttpMethod.POST, "/api/midtrans/sign").hasRole("ADMIN") // Account endpoints - .requestMatchers(HttpMethod.GET, "/api/accounts").hasRole("ADMIN") - .requestMatchers(HttpMethod.POST, "/api/accounts").hasRole("ADMIN") - .requestMatchers(HttpMethod.PATCH, "/api/accounts/{accountId:\\d+}/privilege").hasRole("ADMIN") - .requestMatchers("/api/accounts/**").hasAnyRole("ADMIN", "USER") + // Special case: uses master key or authorization to access this endpoint, defined in + // the controller. Dangerous if not properly secured. +// .requestMatchers(HttpMethod.GET, "/api/accounts").permitAll() +// .requestMatchers(HttpMethod.POST, "/api/accounts").permitAll() +// .requestMatchers(HttpMethod.DELETE, "/api/accounts/**").permitAll() +// .requestMatchers(HttpMethod.PATCH, "/api/accounts/{accountId:\\d+}/privilege").permitAll() + .requestMatchers("/api/accounts**").permitAll() // Closure endpoints .requestMatchers(HttpMethod.POST, "/api/closure").hasRole("ADMIN") @@ -194,7 +197,7 @@ public CorsConfigurationSource corsConfigurationSource() { /* hardcoded here, it's the same as what is supposed to be in frontEndBaseUrl */ // "https://natural-hamster-firstly.ngrok-free.app", /* this is pretty much unecessary since it's the current domain being sit by the program*/ - "https://distinctly-harmless-elephant.ngrok-free.app", + "https://turkey-glad-orca.ngrok-free.app", "http://localhost:80", "http://localhost:443", "http://localhost:5173", @@ -202,7 +205,7 @@ public CorsConfigurationSource corsConfigurationSource() { "http://localhost:62080", "http://localhost:62081" // H2/Swagger UI )); - configuration.setAllowedHeaders(List.of("Content-Type", "Authorization", "X-Requested-With", "Ngrok-Skip-Browser-Warning")); + configuration.setAllowedHeaders(List.of("Content-Type", "Authorization", "X-Requested-With", "X-Api-Key", "Ngrok-Skip-Browser-Warning")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowCredentials(true); diff --git a/src/main/java/dev/kons/kuenyawz/controllers/AccountController.java b/src/main/java/dev/kons/kuenyawz/controllers/AccountController.java index 97ca41e3..3016347e 100644 --- a/src/main/java/dev/kons/kuenyawz/controllers/AccountController.java +++ b/src/main/java/dev/kons/kuenyawz/controllers/AccountController.java @@ -1,15 +1,19 @@ package dev.kons.kuenyawz.controllers; +import dev.kons.kuenyawz.configurations.ApplicationProperties; import dev.kons.kuenyawz.dtos.account.*; import dev.kons.kuenyawz.entities.Account; +import dev.kons.kuenyawz.exceptions.UnauthorizedException; import dev.kons.kuenyawz.mapper.AccountMapper; import dev.kons.kuenyawz.services.entity.AccountService; +import dev.kons.kuenyawz.services.logic.AuthService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -30,6 +34,7 @@ public class AccountController extends BaseController { private final AccountService accountService; private final AccountMapper accountMapper; + private final ApplicationProperties properties; @Operation(summary = "(Master) Get all accounts", description = "Retrieves a list of all accounts with secure information", @@ -41,9 +46,14 @@ public class AccountController extends BaseController { schema = @Schema(implementation = ListOfAccountDto.class) ) ) - @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN"}) + @SecurityRequirements({ + @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN"}), + @SecurityRequirement(name = "xApiKey") + }) @GetMapping public ResponseEntity getAllAccounts() { + ensureRequesterAuthorized(); + List accounts = accountService.getAllAccounts(); return ResponseEntity.status(HttpStatus.OK).body(new ListOfAccountDto(accounts)); @@ -69,11 +79,16 @@ public ResponseEntity getAllAccounts() { ) ) ) - @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN"}) + @SecurityRequirements({ + @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN"}), + @SecurityRequirement(name = "xApiKey") + }) @PostMapping public ResponseEntity createAccount( @Valid @RequestBody AccountRegistrationDto accountRegistrationDto ) { + ensureRequesterAuthorized(); + Account account = accountService.createAccount(accountRegistrationDto); return ResponseEntity.status(HttpStatus.CREATED).body(accountMapper.fromEntity(account)); } @@ -85,11 +100,16 @@ public ResponseEntity createAccount( schema = @Schema(implementation = AccountSecureDto.class) ) ) - @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN", "USER"}) + @SecurityRequirements({ + @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN"}), + @SecurityRequirement(name = "xApiKey") + }) @GetMapping("{accountId}") public ResponseEntity getAccount( @PathVariable Long accountId ) { + ensureRequesterAuthorized(); + Account account = accountService.getAccount(accountId); return ResponseEntity.ok(accountMapper.fromEntity(account)); } @@ -101,23 +121,33 @@ public ResponseEntity getAccount( schema = @Schema(implementation = AccountSecureDto.class) ) ) - @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN", "USER"}) + @SecurityRequirements({ + @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN", "USER"}), + @SecurityRequirement(name = "xApiKey") + }) @PutMapping("{accountId}") public ResponseEntity updateAccount( @PathVariable Long accountId, @Valid @RequestBody AccountPutDto accountPutDto ) { + ensureRequesterAuthorized(); + Account account = accountService.updateAccount(accountId, accountPutDto); return ResponseEntity.ok(accountMapper.fromEntity(account)); } @Operation(summary = "Delete an account", description = "Deletes an account with the provided account ID") @ApiResponse(responseCode = "204", description = "Successfully deleted account") - @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN"}) + @SecurityRequirements({ + @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN", "USER"}), + @SecurityRequirement(name = "xApiKey") + }) @DeleteMapping("{accountId}") public ResponseEntity deleteAccount( @PathVariable Long accountId ) { + ensureRequesterAuthorized(); + accountService.deleteAccount(accountId); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } @@ -131,37 +161,62 @@ public ResponseEntity deleteAccount( schema = @Schema(implementation = AccountSecureDto.class) ) ) - @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN", "USER"}) + @SecurityRequirements({ + @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN", "USER"}), + @SecurityRequirement(name = "xApiKey") + }) @PatchMapping("{accountId}/account") public ResponseEntity patchAccount( @PathVariable Long accountId, @Valid @RequestBody AccountPatchDto accountPatchDto ) { + ensureRequesterAuthorized(); + Account account = accountService.patchAccount(accountId, accountPatchDto); return ResponseEntity.ok(accountMapper.fromEntity(account)); } @Operation(summary = "Patch an account's password", description = "Patches with the provided request body") @ApiResponse(responseCode = "204", description = "Successfully patched account's password") - @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN", "USER"}) + @SecurityRequirements({ + @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN", "USER"}), + @SecurityRequirement(name = "xApiKey") + }) @PatchMapping("{accountId}/password") public ResponseEntity updatePassword( @PathVariable Long accountId, @Valid @RequestBody PasswordUpdateDto passwordUpdateDto ) { + ensureRequesterAuthorized(); + accountService.updatePassword(accountId, passwordUpdateDto); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } @Operation(summary = "Patch an account's privilege", description = "Patches with the provided request body") @ApiResponse(responseCode = "204", description = "Successfully patched account's privilege") - @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN"}) + @SecurityRequirements({ + @SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN"}), + @SecurityRequirement(name = "xApiKey") + }) @PatchMapping("{accountId}/privilege") public ResponseEntity updatePrivilege( @PathVariable Long accountId, @Valid @RequestBody PrivilegeUpdateDto privilegeUpdateDto ) { + ensureRequesterAuthorized(); + accountService.updatePrivilege(accountId, privilegeUpdateDto); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } + + /** + * Helped method to ensure the requester is authorized to perform the action. + * This is specific for this controller so that direct call to Account endpoints are authenticated properly. + */ + private void ensureRequesterAuthorized() { + if (!(AuthService.isAuthenticatedMaster(properties) || AuthService.isAuthenticatedAdmin())) { + throw new UnauthorizedException("This action requires master or admin privileges"); + } + } } diff --git a/src/main/java/dev/kons/kuenyawz/services/entity/AccountServiceImpl.java b/src/main/java/dev/kons/kuenyawz/services/entity/AccountServiceImpl.java index c510501b..b2bc4e8b 100644 --- a/src/main/java/dev/kons/kuenyawz/services/entity/AccountServiceImpl.java +++ b/src/main/java/dev/kons/kuenyawz/services/entity/AccountServiceImpl.java @@ -1,6 +1,5 @@ package dev.kons.kuenyawz.services.entity; -import dev.kons.kuenyawz.dtos.account.*; import dev.kons.kuenyawz.dtos.account.*; import dev.kons.kuenyawz.entities.Account; import dev.kons.kuenyawz.exceptions.AccountExistsException; diff --git a/src/main/java/dev/kons/kuenyawz/services/logic/AuthService.java b/src/main/java/dev/kons/kuenyawz/services/logic/AuthService.java index a36f0c0c..31f962f5 100644 --- a/src/main/java/dev/kons/kuenyawz/services/logic/AuthService.java +++ b/src/main/java/dev/kons/kuenyawz/services/logic/AuthService.java @@ -1,14 +1,17 @@ package dev.kons.kuenyawz.services.logic; +import dev.kons.kuenyawz.configurations.ApplicationProperties; import dev.kons.kuenyawz.dtos.account.AccountRegistrationDto; import dev.kons.kuenyawz.dtos.account.AccountSecureDto; import dev.kons.kuenyawz.dtos.auth.AuthRequestDto; import dev.kons.kuenyawz.dtos.auth.AuthResponseDto; import dev.kons.kuenyawz.entities.Account; import dev.kons.kuenyawz.exceptions.UnauthorizedException; -import jakarta.persistence.EntityNotFoundException; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; public interface AuthService { /** @@ -84,14 +87,22 @@ public interface AuthService { // Static authentication methods + /** + * Gets the {@link Account} from the current request. + * @return {@link Account} the authenticated account + */ static Account getAuthenticatedAccount() { Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if (principal instanceof Account) { return (Account) principal; } - throw new EntityNotFoundException("Account not found"); + throw new UnauthorizedException("Couldn't get the authenticated account from the request context"); } + /** + * Validates whether the request is an authenticated admin. + * @throws UnauthorizedException if the request is not an admin + */ static void validateIsAdmin() { Account account = getAuthenticatedAccount(); if (account.getPrivilege() != Account.Privilege.ADMIN) { @@ -99,6 +110,11 @@ static void validateIsAdmin() { } } + /** + * Matches the authenticated account with the given account ID. + * @param accountId the account ID to match + * @throws UnauthorizedException if the account ID does not match the authenticated account + */ static void validateMatchesId(Long accountId) { Account account = getAuthenticatedAccount(); if (!accountId.equals(account.getAccountId())) { @@ -106,18 +122,59 @@ static void validateMatchesId(Long accountId) { } } + /** + * Check whether the request's token is an admin. + * @return {@code true} if the request is an admin, {@code false} otherwise + */ static boolean isAuthenticatedAdmin() { Account account = getAuthenticatedAccount(); return account.getPrivilege() == Account.Privilege.ADMIN; } + /** + * Check whether the request's token is a user. + * @return {@code true} if the request is a user, {@code false} otherwise + */ static boolean isAuthenticatedUser() { Account account = getAuthenticatedAccount(); return account.getPrivilege() == Account.Privilege.USER; } + /** + * Check whether the request is using a valid master key. + * + * @param properties invoker class's application properties + * @return {@code true} if the request is using a valid master key, {@code false} otherwise/null + */ + static boolean isAuthenticatedMaster(ApplicationProperties properties) { + if (properties.getMasterKey() == null) { + return false; + } + + HttpServletRequest request = getCurrentRequest(); + if (request == null) { + return false; + } + + String xApiKey = request.getHeader("X-Api-Key"); + System.out.println("X-Api-Key: " + xApiKey); + if (xApiKey == null || xApiKey.isBlank()) { + return false; + } + return xApiKey.equals(properties.getMasterKey()); + } + static boolean authenticatedAccountEquals(Long accountId) { Account account = getAuthenticatedAccount(); return account.getAccountId().equals(accountId); } + + /** + * Gets current request's servlet request. + * @return {@link HttpServletRequest} the current request + */ + private static HttpServletRequest getCurrentRequest() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return attributes != null ? attributes.getRequest() : null; + } }