jimble

Login and authorization#

No annotations. One before, plus attributes on the routes.

public class App extends JimbleApp {

	{
		before(Auth::guard);                                    // that is all

		path("/public", () -> {
			attribute(Auth.PUBLIC, true);                       // the whole block is public
			attribute(Auth.NO_SESSION, true);
			get("/guide", Guide::show);
		});

		get("/login",  Login::show).attribute(Auth.PUBLIC, true);
		post("/login", Login::submit).attribute(Auth.PUBLIC, true);

		get("/requests",  RequestController::list);             // needs a login by default
		get("/approvals", ApprovalController::list).attribute(Auth.ROLE, "approver");
	}

}

Register before(Auth::guard) first. It decides whether the request uses a session, so anything that touches session() before it wins the race.

Closed by default#

Auth.PUBLIC defaults to false — a login is required.

A route someone adds without writing anything is closed. The other way round, a route they forgot about is silently open. Both are the same mistake; what differs is which way it falls.

Route attributes#

Attribute Default What it decides
Auth.PUBLIC false Whether a login is not required
Auth.ROLE "" The role required. Empty means any
Auth.NO_SESSION false Whether to skip sessions entirely
Auth.FULL_AUTH false Whether only someone who just typed their password may pass (below)

Write it on a block and it applies to every route in it (Routing). Override it on the one route that differs.

Trap

PUBLIC and NO_SESSION are separate decisions. The login endpoints need no login but do need a session — that is where the CSRF token and the post-login session live.

Tie them together and the login page cannot hold a session, so nobody can log in (the 302 comes back fine and the next request is a 401 — we walked into this one).

NO_SESSION belongs only on genuinely public pages.

Logging someone in#

Data staff = findStaff(loginId);

if (!Auth.checkPassword(password, staff.isEmpty() ? null : staff.getString("password_hash"))) {
	context.flash().put("message", "Wrong login id or password");
	context.response().redirect("/login");
	return;
}

Auth.login(context, Principal.of(
	staff.getLong("id"), staff.getString("name"), staff.getString("role")));

context.response().redirect("/me");

Auth.attemptLogin does the whole thing: make them wait, check, and clear the count on success. Auth.login regenerates the session id before storing anything, and saves (Sessions and safe defaults).

Trap

Do not call PasswordUtil.check directly here. With a null hash it returns false immediately, so a user that does not exist answers measurably faster (being slow is BCrypt's whole job). That timing lets someone enumerate which ids exist.

Auth.attemptLogin runs one round anyway before returning false. Do not split the message either — that undoes the point of matching the timing.

When someone keeps getting it wrong#

Auth.attemptLogin counts the failures and makes the next attempt wait. There is nothing to write — the code above already does it.

failures 1-3 ... no wait (typos)
4th ... 1 second
5th ... 2 seconds
6th ... 4 seconds     ... up to the maximum (300 seconds by default)

While the wait is still running it answers 429 with Retry-After (like 401, whether that becomes a redirect is your error() handler's decision). After a 24 hour gap the count starts again.

It is not "N failures, locked for M minutes". Anything that stops an account is a harassment tool as it stands — getting it wrong on purpose locks that person out. Doubling the wait instead makes the attacker's rate effectively zero while a real user waits a few seconds.

Trap

Count by the login id that was typed in. Pass the found user's database id and an id that does not exist is never counted — brute force starts with ids that do not exist, and whether you are made to wait then tells someone which ids are real.

Where The auth_attempt table. Does nothing without a DB (it says so in the log, once)
Keyed by The login id (case and surrounding spaces are normalised, then SHA-256. It is not stored in the clear)
Settings auth.lockout.* (Configuration)
Cleanup Automatic (once an hour, while counting a failure). Lockout.cleanup() if you want it by hand
Releasing one Lockout.clear(loginId)

This does not replace rate limiting. Rate limiting counts per IP, so one attempt each from a thousand IPs against one account never fires. This counts per account, whoever it comes from. Use both.

Who is logged in#

Principal me = Auth.principal(context);

me.id();                  // 0 means nobody
me.name();
me.hasRole("approver");

It never returns null. Nobody logged in gives you Principal.ANONYMOUS.

A Principal carries three things: id, display name, role. Put the whole user object in the session instead and:

  • fixing a name in the DB leaves the old one until they log in again
  • revoking a role does nothing until the session expires
  • with cookie sessions, all of it travels to the browser

Load the rest from the DB when you need it. If that is expensive, that is what the cache is for.

Logging out#

Auth.logout(context);

The whole session goes. Clear only the login keys and the shopping cart or the draft is still there for the next person (which matters on a shared machine).

Returning 401 and 403#

Auth.guard only throws an HttpException. Whether that becomes a redirect or JSON is your error() handler's decision.

error((context, cause, statusCode) -> {

	if (statusCode == 401 && !context.request().acceptJson()) {
		context.response().redirect("/login");
		return;
	}

	context.response().code(statusCode).json("error", cause.getMessage());

});

A missing role is a 403, not a 401. The status code is how you say that logging in again will not change the answer. Return 401 and people keep trying, believing another attempt will get them in.

Staying logged in (remember-me)#

{
	before(Remember.restore(App::findPrincipal));   // remember first
	before(Auth::guard);                            // then guard

	post("/password", Password::change).attribute(Auth.FULL_AUTH, true);
}

// Look the user up again by id. The role comes from here, so revoking one takes effect at once
private static Principal findPrincipal (long id) {
	Data staff = findStaff(id);
	return staff.isEmpty() ? null
		: Principal.of(staff.getLong("id"), staff.getString("name"), staff.getString("role"));
}

At login, remember them only when the box was ticked.

Auth.login(context, principal);

if ("1".equals(request.getString("remember"))) {
	Remember.issue(context, principal);
}

Trap

Register before(Remember.restore(...)) before before(Auth::guard). The other way round, guard decides nobody is logged in and only then do you remember them.

Do not call issue unconditionally — on a shared machine the next person gets in.

This is what makes remember-me safe enough to offer. Someone who came back through the cookie is not "someone who just typed their password", so a route with attribute(Auth.FULL_AUTH, true) answers 401.

Put it on password changes, account deletion, payments, and contact details. In code it is Auth.fullyAuthenticated(context), but the route attribute is the one you cannot forget to write.

Theft shows up#

The cookie holds selector:validator, and the validator is replaced on every use. A stolen cookie and the real one cannot both stay current, so a value from before the last rotation is the signal that one of them was copied.

On that signal every remembered login for that user is deleted (and it goes in the log). Which of the two used it first cannot be told apart, so deleting only one can leave the real user locked out and the thief still in.

Where The auth_remember table. Does nothing without a DB
Cookie selector:validator. The validator is stored as SHA-256 (the selector is just the lookup key, so it is stored as is)
Expiry 30 days since last use, and 90 days since it was issued (it always expires eventually, however much you use it)
Grace For 60 seconds after a rotation the old one still works, so parallel requests do not log people out
Logout Auth.logout deletes it
Password change Call Remember.forgetAll(userId) (below)
Settings auth.remember.* (Configuration)

Trap

Call Remember.forgetAll(userId) when the password changes. Without it a stolen cookie still works — which is the whole point of changing the password. It is also what "log out everywhere" is.

Basic auth#

Operational endpoints can use Basic auth (Requests and responses).

path("/ops", () -> {
	before(BasicAuth.of("ops", System.getenv("OPS_PASSWORD")));
	attribute(Auth.PUBLIC, true);      // a different mechanism from the session login
	get("/whoami", Ops::whoami);
});

What this does not do#

Annotations (@PreAuthorize and friends) Not used, per the principles. Routes declare it as an attribute
Showing the user how many days are left Not offered. Read the row yourself
JWT Deliberately not offered. You cannot revoke one, and it adds key management. If you need API auth, use an opaque token held in the DB
OAuth / OIDC / SAML Not yet
A permission table Roles are plain strings

A working one is in examples/approval-auth.