DEV Community

Cover image for Access Control and Zero-Knowledge
Michael M.
Michael M.

Posted on

Access Control and Zero-Knowledge

Recap

In the previous parts of the series, we implemented a backend capable of securely authenticating users and a functional frontend application.

In this part, we will protect our app's (and users') data by introducing an access control mechanism and further securing users' authentication.

Role-Based Access Control

If you remember, previously, we intentionally skipped any route validations within the frontend application, so if you open the "/account" page without signing in, two endpoints will be queried:

  • The /api/account/check/public will return a successful response

  • The /api/account will return an error caused by the "user_not_found" exception

While this seems correct at first, the issue here is the exception's origin – that service shouldn't have been invoked in the first place. What if the data queried by that endpoint didn't require the user's ID? This could expose unnecessary information or services.

Let's protect that endpoint so that an unauthorized request never reaches it. Remember how we added the user's role to the token withClaim(claimUserType, userType) and then, in the JwtFilter, put it into the Security Context new SimpleGrantedAuthority(tokenData.getUserType())? This would help us filter requests using Spring's annotation:

@PreAuthorize("hasAnyAuthority('admin', 'user')")
public ResponseEntity<UserInfo> getUser() { ... }

...
Enter fullscreen mode Exit fullscreen mode

[AccountController.java]

Here, we marked this endpoint with the @PreAuthorize annotation, asking Spring to check if the user making the request has any of the listed roles. If the criteria don't match, Spring will entirely prevent the request from reaching this endpoint.

Since we don't display any greeting on the account page if the request fails, let's add one more endpoint for the demonstration:

...

@GetMapping(value = "/check/protected", produces = MediaType.TEXT_PLAIN_VALUE)
@PreAuthorize("hasAnyAuthority('admin', 'user')")
public ResponseEntity<String> getProtected() {
    return ResponseEntity.status(HttpStatus.OK)
            .body("This data is available only when signed in.");
}
Enter fullscreen mode Exit fullscreen mode

[AccountController.java]

Once again, launch the backend and execute npm run api-gen to update our frontend services. As a final touch, query this endpoint along with the public one:

public ngOnInit(): void {
    this.loadData(this.accountService.getPublic(), this.responsePublic$);
    this.loadData(this.accountService.getProtected(), this.responseProtected$);
    ...
}
Enter fullscreen mode Exit fullscreen mode

[account.component.ts]

And display the result:

<div class="c-split">
    <span class="text-split">Protected endpoint:</span>
    @if (responseProtected$ | async; as response) {
    <span class="text-split">{{ response }}</span>
    }
</div>
Enter fullscreen mode Exit fullscreen mode

[account.component.ts]

Now, launch the frontend as well and navigate to the account page manually without signing in. All three endpoints would be queried simultaneously, but only one would return with a successful response (you will see the HTTP status of the protected endpoint, though). But after you sign in, all of them will return something useful.

In a SaaS platform, for example, protecting sensitive endpoints with access control is essential to ensuring users only access what they're authorized for, preventing data leaks or misconfiguration vulnerabilities.

Practice: Now that you've set up access control, try experimenting with additional roles or creating different endpoint restrictions. How would you allow specific users to access something, and what are the potential pitfalls?

Zero-Knowledge

With all the basics set up, I may now introduce you to the zero-knowledge password storage. The idea behind this is very simple: I, as the owner of the web service, take full responsibility for the user's personal information, if applicable, but I don't want to have even the slightest chance to know their passwords and be responsible for credentials in case of a leakage.

Okay, but how do we achieve this? We need to store some kind of credentials anyway to let users in, and the answer would be double hashing.

Hashing passwords with modern algorithms already prevents storing raw passwords in the database. Even if attackers gain access to the hashes, reversing them would be computationally, and financially, expensive. Double hashing adds an extra layer by ensuring that even if someone knows the hashed format, they still won't have access to the raw data unless they're willing to spend even more resources on brute-forcing. Let's see the implementation.

First, we will add a dependency to the frontend app npm i hash.js, and since this package is a CommonJS one, we let Angular know that we're OK with that:

...
"options": {
    ...
    "allowedCommonJsDependencies": ["hash.js"]
}
Enter fullscreen mode Exit fullscreen mode

[project.json]

Now, all we have to do is slightly change the login (and signup) form submission logic:

const formValue = this.form.getRawValue();
const hashedValue = sha256()
    .update(formValue.password || '')
    .digest('hex');

this.authService
    .login({ body: { username: formValue.username, password: hashedValue } })
Enter fullscreen mode Exit fullscreen mode

[login.component.ts]

This will invalidate all existing password hashes stored in the database, so if you would like to introduce this to an existing production application, you would need to implement a smooth migration strategy.

Finally, let's make sure that network errors or brute-force attacks won't mess with our database performance by adding an extra validation to the "password" field:

...

@Size(min = 64, max = 64, message = "Incorrect password format")
@Pattern(regexp = "^[\\da-f]+$", message = "Incorrect password format")
private String password;
Enter fullscreen mode Exit fullscreen mode

[LoginRequest.java]

Now, the only time our application knows the real user's password is the moment between the form input and page navigation. The backend does not know the original value at any point, and you may see it for yourself using the Network tab of Dev Tools.

Conclusion

In this part, we've added more security measures to the application, namely access control and zero-knowledge password storage.

As usual, all the code fragments in this article are in the companion repository, which you can clone or browse online. If you feel any area needs further clarification in the article itself, feel free to point in the comments, and I'll do my best.

Up next is the multi-factor authentication and the seemingly complex (not really) WebAuthn.


Cover image by Joshua Sortino on Unsplash

Top comments (0)

Instrument, monitor, fix: a hands-on debugging session

Join Lazar for a hands-on session where you’ll build it, break it, debug it, and fix it. You’ll set up Sentry, track errors, use Session Replay and Tracing, and leverage some good ol’ AI to find and fix issues fast.

Tune in to the full event

DEV is partnering to bring live events to the community. Join us or dismiss this billboard if you're not interested. ❤️