Skip to content

Commit

Permalink
Merge pull request #310 from vianneynara/feature/307-setup-master-key…
Browse files Browse the repository at this point in the history
…-for-account-exposed-endpoints-on-controller-layer

Setup master key for account exposed endpoints on controller layer
  • Loading branch information
vianneynara authored Dec 20, 2024
2 parents d3f2845 + a0cce79 commit 8f16405
Show file tree
Hide file tree
Showing 8 changed files with 159 additions and 25 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ APP_SERVER_HOST=
APP_SERVER_PORT=
APP_VERSION=
APP_REPOSITORY_URL=
APP_MASTER_KEY=

# Front end links
FRONTEND_BASE_URL=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> acceptedImageExtensions;
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -194,15 +197,15 @@ 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",
"http://localhost:8081", // H2/Swagger UI
"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);

Expand Down
71 changes: 63 additions & 8 deletions src/main/java/dev/kons/kuenyawz/controllers/AccountController.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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",
Expand All @@ -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<Object> getAllAccounts() {
ensureRequesterAuthorized();

List<AccountSecureDto> accounts = accountService.getAllAccounts();

return ResponseEntity.status(HttpStatus.OK).body(new ListOfAccountDto(accounts));
Expand All @@ -69,11 +79,16 @@ public ResponseEntity<Object> getAllAccounts() {
)
)
)
@SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN"})
@SecurityRequirements({
@SecurityRequirement(name = "cookieAuth", scopes = {"ADMIN"}),
@SecurityRequirement(name = "xApiKey")
})
@PostMapping
public ResponseEntity<Object> createAccount(
@Valid @RequestBody AccountRegistrationDto accountRegistrationDto
) {
ensureRequesterAuthorized();

Account account = accountService.createAccount(accountRegistrationDto);
return ResponseEntity.status(HttpStatus.CREATED).body(accountMapper.fromEntity(account));
}
Expand All @@ -85,11 +100,16 @@ public ResponseEntity<Object> 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<Object> getAccount(
@PathVariable Long accountId
) {
ensureRequesterAuthorized();

Account account = accountService.getAccount(accountId);
return ResponseEntity.ok(accountMapper.fromEntity(account));
}
Expand All @@ -101,23 +121,33 @@ public ResponseEntity<Object> 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<Object> 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<Object> deleteAccount(
@PathVariable Long accountId
) {
ensureRequesterAuthorized();

accountService.deleteAccount(accountId);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
Expand All @@ -131,37 +161,62 @@ public ResponseEntity<Object> 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<Object> 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<Object> 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<Object> 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");
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading

0 comments on commit 8f16405

Please sign in to comment.