- Scaffolding
- Fundamentals
- Authentication
- Error Handling
- Not Founds
- Configuration
- CSRF protection
- Delivery of static assets
Below is one approach to a project layout. Which can be observed in the hello world application.
project/
src/
main/
java/{groupId}.{artifactId}
config/
AppConfig.java
AppEntryServlet.java
server/
AppServer.java
resources/
webapp/
public/
WEB-INF/
jsp/
test/
AppConfig.java
contains the configuration to set up your web application.
AppEntryServlet.java
allows servlet container requests to be sent to otter.
AppServer.java
has the application's main method to start the web application.
A Resource handles an http request. it's typically used to accept text/html
, however, It can accept any Content-Type
.
A RestResource is designed to accept and reply application/json
. Sorry, there is no support for application/xml
.
A Between allows a rule to be executed before a request reaches a Resource or after a Resource executes. Also referred to as a before and a after.
A Target instructs otter which Resource
to use for a given url and http methods.
Target<TokenSession, User> hello = new TargetBuilder<TokenSession, User>()
.groupName(WEB_SITE_GROUP)
.method(Method.GET)
.resource(new HelloResource())
.regex(HelloResource.URL)
.build();
A Group allows sharing betweens and error handling with Targets
.
var serverErrorResource = new ServerErrorResource();
Group<TokenSession, User> webSiteGroup = new GroupBuilder<TokenSession, User>()
.name(WEB_SITE_GROUP)
.sessionClazz(TokenSession.class)
.before(Label.AUTH_OPTIONAL, new AuthOptBetween())
.before(Label.AUTH_REQUIRED, new AuthBetween())
.onError(StatusCode.SERVER_ERROR, serverErrorResource)
.build();
To share a Group's
feature with a Target
set the .groupName(..)
ot the name(..)
of the Group
.
Target<TokenSession, User> hello = new TargetBuilder<TokenSession, User>()
.groupName(WEB_SITE_GROUP)
.method(Method.GET)
.resource(new HelloResource())
.regex(HelloResource.URL)
.build();
All Targets
must relate to a Group
.
A RestBetween allows a rule to be executed before a request reaches a RestResource or after a RestResource executes. Also referred to as a before and a after.
A RestTarget instructs otter which RestResource
to use for a given url and http methods.
var helloRestResourceV3 = new HelloRestResource();
RestTarget<ApiUser, Hello> helloApiV3 = new RestTargetBuilder<ApiUser, Hello>()
.groupName(API_GROUP_V3)
.method(Method.GET)
.method(Method.POST)
.restResource(helloRestResourceV3)
.regex(helloRestResourceV3.URL)
.authenticate()
.contentType(json)
.accept(json)
.payload(Hello.class)
.build();
A RestGroup allows sharing betweens and error handling with RestTargets
.
BadRequestResource badRequestResource = new BadRequestResource();
ServerErrorResource serverErrorResource = new ServerErrorResource();
RestGroup<DummySession, ApiUser> apiGroupV3 = new RestGroupBuilder<DummySession, ApiUser>()
.name(API_GROUP_V3)
.sessionClazz(DefaultSession.class)
.before(Label.AUTH_OPTIONAL, authRestBetween)
.before(Label.AUTH_REQUIRED, authRestBetween)
.onError(StatusCode.BAD_REQUEST, badRequestResource, BadRequestPayload.class)
.onError(StatusCode.SERVER_ERROR, serverErrorResource, ServerErrorPayload.class)
.build();
To share a RestGroup's
feature with a RestTarget
set the .groupName(..)
to the name(..)
of the RestGroup
.
var helloRestResourceV3 = new HelloRestResource();
RestTarget<ApiUser, Hello> helloApiV3 = new RestTargetBuilder<ApiUser, Hello>()
.groupName(API_GROUP_V3)
.method(Method.GET)
.method(Method.POST)
.restResource(helloRestResourceV3)
.regex(helloRestResourceV3.URL)
.authenticate()
.contentType(json)
.accept(json)
.payload(Hello.class)
.build();
All RestTargets
must relate to a RestGroup
.
A request body that is intended to be delivered to a RestResource
will be validated by
using javax.validation
defined by JSR 380
. To use it annotate a class implementation with constraints then if
there are any errors Otter will send the request to the configured RestBadRequestResource
.
See error handling for how that works.
If javax.validation
is not your preference then it can be replaced by passing in an implementation of Otter's
Validate
interface into the RestTargetBuilder
.
Validate customValidate = new CustomValidate();
var helloRestResourceV3 = new HelloRestResource();
RestTarget<ApiUser, Hello> helloApiV3 = new RestTargetBuilder<ApiUser, Hello>()
.groupName(API_GROUP_V3)
.method(Method.GET)
.method(Method.POST)
.restResource(helloRestResourceV3)
.regex(helloRestResourceV3.URL)
.validate(customValidate) // your preferred validate implemention.
.contentType(json)
.accept(json)
.payload(Hello.class)
.build();
Authentication in otter is dependent on the value objects:
Two different authentication betweens must be implemented.
Configure the betweens to be used by a Group
or RestGroup
.
A Session
is a cookie that is http-only
and it's value is a JWE
. It is required to have authentication for
Resources
Sessions in otter are stateless.
- There is no state stored on the web server.
- You can use data stored in the session to retrieve the user's profile, such as a access token or session id.
Session don'ts:
- Do not put data into the session that may become stale, such as RBAC.
Session implementations:
- Must extend DefaultSession
- Must have a copy constructor.
Why JWE?
The threats are:
- Session hijacking by modifying values of the session cookie to take over a different session.
- In the instance the session cookie is revealed then sensitive data is not easily accessible.
When a session fails to be read or does not exist when it should then Otter will return:
- status code of 401
- remove session cookie
This behavior can be overridden by implementing a halt bifunction and passing it into it's group.
Group<TokenSession, User> webSiteGroup = new GroupBuilder<TokenSession, User>()
.name(WEB_SITE_GROUP)
.sessionClazz(TokenSession.class)
.before(Label.AUTH_OPTIONAL, new AuthOptBetween())
.before(Label.AUTH_REQUIRED, new AuthBetween())
.onHalt(Halt.SESSION, (Response<TokenSession> response, HaltException e) -> {
response.setTemplate(Optional.of("/WEB-INF/jsp/401.jsp"));
response.setStatusCode(StatusCode.UNAUTHORIZED);
return response;
})
.onError(StatusCode.SERVER_ERROR, serverErrorResource)
.build();
Custom implementations of session managment can be injected into Groups and RestGroups
Implement betweens that will handle reading and writing sessions. Then inject them into the group.
Group<TokenSession, User> webSiteGroup = new GroupBuilder<TokenSession, User>()
.name(WEB_SITE_GROUP)
.sessionClazz(TokenSession.class)
.before(Label.AUTH_OPTIONAL, new AuthOptBetween())
.before(Label.AUTH_REQUIRED, new AuthBetween())
.before(Label.SESSION_REQUIRED, readSession) // <-- custom required session reading
.before(Label.SESSION_OPTIONAL, readSessionOptional) // <-- custom optional session reading
.before(Label.SESSION_REQUIRED, writeSession) // <-- custom required session writing
.before(Label.SESSION_OPTIONAL, writeSessionOptional) // <-- custom optional session writing
.onError(StatusCode.SERVER_ERROR, serverErrorResource)
.build();
These would then be used instead of the defaults for all the targets in that group.
Implement betweens that will handle reading and writing sessions. Then inject them into the restGroup.
BadRequestResource badRequestResource = new BadRequestResource();
ServerErrorResource serverErrorResource = new ServerErrorResource();
RestGroup<DummySession, ApiUser> apiGroupV3 = new RestGroupBuilder<DummySession, ApiUser>()
.name(API_GROUP_V3)
.sessionClazz(DefaultSession.class)
.before(Label.AUTH_OPTIONAL, authRestBetween)
.before(Label.AUTH_REQUIRED, authRestBetween)
.before(Label.SESSION_REQUIRED, readSession) // <-- custom required session reading
.before(Label.SESSION_OPTIONAL, readSessionOptional) // <-- custom optional session reading
.before(Label.SESSION_REQUIRED, writeSession) // <-- custom required session writing
.before(Label.SESSION_OPTIONAL, writeSessionOptional) // <-- custom optional session writing
.onError(StatusCode.BAD_REQUEST, badRequestResource, BadRequestPayload.class)
.onError(StatusCode.SERVER_ERROR, serverErrorResource, ServerErrorPayload.class)
.build();
It would then be used instead of the default for all the restTargets in that restGroup.
User implementations:
- Must extend DefaultUser
Given the required authentication between
When authentication succeeds
Then assign the request user to the appropriate user
When authentication fails
Then possibly set the status code to 401
And throw a halt exception.
Given the optional authentication between
When the Session is present
and authentication succeeds
Then assign the request user to the appropriate user
When the Session is present
and authentication fails
Then possibly set the status code to 401
And throw a halt exception.
When the Session is not present
Then allow the request to reach the resource.
var serverErrorResource = new ServerErrorResource();
ErrorTarget<TokenSession, User> mediaType = new ErrorTargetBuilder<TokenSession, User>()
.resource(new MediaTypeResource())
.build();
Group<TokenSession, User> webSiteGroup = new GroupBuilder<TokenSession, User>()
.name(WEB_SITE_GROUP)
.sessionClazz(TokenSession.class)
.before(Label.AUTH_OPTIONAL, new AuthOptBetween())
.before(Label.AUTH_REQUIRED, new AuthBetween())
.onError(StatusCode.SERVER_ERROR, serverErrorResource)
.onDispatchError(StatusCode.UNSUPPORTED_MEDIA_TYPE, mediaType)
.build();
Then to require authentication for a Resource
use, .authenticate()
.
Target<TokenSession, User> hello = new TargetBuilder<TokenSession, User>()
.groupName(WEB_SITE_GROUP)
.method(Method.GET)
.resource(new HelloResource())
.regex(HelloResource.URL)
.authenticate()
.build();
If authenticate()
is not used then the optional authenticate between will be executed.
Use anonymous()
to not require authentication or optionally authenticate.
AuthRestBetween authRestBetween = new AuthRestBetween();
RestGroup<DummySession, ApiUser> apiGroupV3 = new RestGroupBuilder<DummySession, ApiUser>()
.name(API_GROUP_V3)
.sessionClazz(DefaultSession.class)
.before(Label.AUTH_OPTIONAL, authRestBetween)
.before(Label.AUTH_REQUIRED, authRestBetween)
.build();
Then, to require authentication for a RestResource
use, .authenticate()
.
var helloRestResourceV3 = new HelloRestResource();
RestTarget<ApiUser, Hello> helloApiV3 = new RestTargetBuilder<ApiUser, Hello>()
.groupName(API_GROUP_V3)
.method(Method.GET)
.method(Method.POST)
.restResource(helloRestResourceV3)
.regex(helloRestResourceV3.URL)
.authenticate()
.contentType(json)
.accept(json)
.payload(Hello.class)
.build();
Here is an example on how to get read access to the session in the before rest betweens. This requires the session is present or it will halt the request.
var helloRestResourceV3 = new HelloRestResource();
RestTarget<ApiUser, Hello> helloApiV3 = new RestTargetBuilder<ApiUser, Hello>()
.groupName(API_GROUP_V3)
.method(Method.GET)
.method(Method.POST)
.restResource(helloRestResourceV3)
.regex(helloRestResourceV3.URL)
.session() // <-- this will set the session.
.authenticate()
.contentType(json)
.accept(json)
.payload(Hello.class)
.build();
If authenticate()
is not used then the optional authenticate between will be executed.
Use anonymous()
to not require authentication or optionally authenticate.
The errors that can be recovered from are:
- Server Error
500
- Unsuppored Media Type
415
- Not Acceptable
406
Everything else should be able to be handled with in a Resource
.
Otter does not have default error handling when an error occurs attempting to reach a Resource
.
To configure a Group
to apply error handlers to all its related Targets
.
var serverErrorResource = new ServerErrorResource<TokenSession, User>("/WEB-INF/jsp/500.jsp");
ErrorTarget<TokenSession, User> mediaType = new ErrorTargetBuilder<TokenSession, User>()
.resource(new MediaTypeResource<TokenSession, User>("/WEB-INF/jsp/415.jsp"))
.build();
ErrorTarget<TokenSession, User> notAcceptable = new ErrorTargetBuilder<TokenSession, User>()
.resource(new NotAcceptableResource<TokenSession, User>("/WEB-INF/jsp/406.jsp"))
.build();
Group<TokenSession, User> webSiteGroup = new GroupBuilder<TokenSession, User>()
.name(WEB_SITE_GROUP)
.sessionClazz(TokenSession.class)
.before(Label.AUTH_OPTIONAL, new AuthOptBetween())
.before(Label.AUTH_REQUIRED, new AuthBetween())
.onError(StatusCode.SERVER_ERROR, serverErrorResource)
.onDispatchError(StatusCode.UNSUPPORTED_MEDIA_TYPE, mediaType)
.onDispatchError(StatusCode.NOT_ACCEPTABLE, notAcceptable)
.build();
To override or add error handling to a Target
.
var serverErrorResource = new ServerErrorResource<TokenSession, User>("/WEB-INF/jsp/500.jsp");
ErrorTarget<TokenSession, User> mediaType = new ErrorTargetBuilder<TokenSession, User>()
.resource(new MediaTypeResource<TokenSession, User>("/WEB-INF/jsp/415.jsp"))
.build();
ErrorTarget<TokenSession, User> notAcceptable = new ErrorTargetBuilder<TokenSession, User>()
.resource(new NotAcceptableResource<TokenSession, User>("/WEB-INF/jsp/406.jsp"))
.build();
Target<TokenSession, User> hello = new TargetBuilder<TokenSession, User>()
.groupName(WEB_SITE_GROUP)
.method(Method.GET)
.resource(new HelloResource())
.regex(HelloResource.URL)
.onError(StatusCode.SERVER_ERROR, serverErrorResource)
.onDispatchError(StatusCode.UNSUPPORTED_MEDIA_TYPE, mediaType)
.onDispatchError(StatusCode.NOT_ACCEPTABLE, notAcceptable)
.build();
Otter will use it's own default handling for:
- Server Error
500
- Bad Request
400
- Not Acceptable
406
- UnSupported Media Type
415
Server Error 500
$ curl -H "Content-Type: application/json; charset=utf-8" -i http://localhost:8080/rest/v2/broken
HTTP/1.1 500 Server Error
Date: Sat, 17 Aug 2019 16:38:53 GMT
Content-Length: 43
{
"message": "An unexpected error occurred."
}
Bad Request 400
$ curl -X POST -H "Content-Type: application/json; charset=utf-8" -H "Accept: application/json; charset=utf-8;" -i http://localhost:8080/rest/v3/hello
HTTP/1.1 400 Bad Request
Date: Sat, 17 Aug 2019 16:35:54 GMT
Content-Length: 102
{
"causes": [
{
"source": "BODY",
"key": null,
"actual": null,
"expected": null,
"reason": "The payload could not be parsed."
}
]
}
Not Supported 406
$ curl -X GET -H "Content-Type: application/json; charset=utf-8" -i http://localhost:8080/rest/v2/hello
HTTP/1.1 406 Not Acceptable
Date: Mon, 21 Oct 2019 11:53:29 GMT
Content-Length: 110
{
"causes": [
{
"source": "HEADER",
"key": "ACCEPT",
"actual": null,
"expected":
["application/json; charset=utf-8;"],
"reason":null
}
]
}
Unsupported Media Type 415
$ curl -i http://localhost:8080/rest/v2/hello
HTTP/1.1 415 Unsupported Media Type
Date: Sat, 17 Aug 2019 16:30:01 GMT
Content-Length: 124
{
"causes": [
{
"source": "HEADER",
"key": "CONTENT_TYPE",
"actual": "null/null;",
"expected": [
"application/json; charset=utf-8;"
],
"reason": null
}
]
}
The errors that can be recovered from are:
- Server Error
500
- Bad Request
400
- Not Supported
406
- Unsuppored Media Type
415
Everything else should be able to be handled with in a RestResource
.
To configure a RestGroup
to apply error handlers to all its related RestTargets
.
// has overrides for error handling.
BadRequestResource badRequestResource = new BadRequestResource();
ServerErrorRestResource serverErrorResource = new ServerErrorRestResource();
RestResource<ApiUser, ClientError> notAcceptableRestResource = new NotAcceptableRestResource<>();
RestErrorTarget<ApiUser, ClientError> notAcceptableTarget = new RestErrorTargetBuilder<ApiUser, ClientError>()
.payload(ClientError.class)
.resource(notAcceptableRestResource)
.build();
RestResource<ApiUser, ClientError> mediaTypeResource = new MediaTypeRestResource<>();
RestErrorTarget<ApiUser, ClientError> mediaTypeTarget = new RestErrorTargetBuilder<ApiUser, ClientError>()
.payload(ClientError.class)
.resource(mediaTypeResource)
.build();
RestGroup<DummySession, ApiUser> apiGroupV3 = new RestGroupBuilder<DummySession, ApiUser>()
.name(API_GROUP_V3)
.sessionClazz(DefaultSession.class)
.before(Label.AUTH_OPTIONAL, authRestBetween)
.before(Label.AUTH_REQUIRED, authRestBetween)
.onError(StatusCode.BAD_REQUEST, badRequestResource, BadRequestPayload.class)
.onError(StatusCode.SERVER_ERROR, serverErrorResource, ServerErrorPayload.class)
.onDispatchError(StatusCode.UNSUPPORTED_MEDIA_TYPE, mediaTypeTarget)
.onDispatchError(StatusCode.NOT_ACCEPTABLE, notAcceptableTarget)
.build();
To override or add error handling to a RestTarget
.
BadRequestResource badRequestResource = new BadRequestResource();
ServerErrorResource serverErrorResource = new ServerErrorResource();
RestResource<ApiUser, ClientError> mediaTypeResource = new MediaTypeResource<>();
RestErrorTarget<ApiUser, ClientError> mediaTypeTarget = new RestErrorTargetBuilder<ApiUser, ClientError>()
.payload(ClientError.class)
.resource(mediaTypeResource)
.build();
RestTarget<ApiUser, Hello> helloApiV2 = new RestTargetBuilder<ApiUser, Hello>()
.groupName(API_GROUP_V2)
.method(Method.GET)
.method(Method.POST)
.restResource(new HelloRestResource())
.regex(HelloRestResource.URL)
.authenticate()
.contentType(json)
.payload(Hello.class)
.onError(StatusCode.BAD_REQUEST, badRequestResource, BadRequestPayload.class)
.onError(StatusCode.SERVER_ERROR, serverErrorResource, ServerErrorPayload.class)
.onDispatchError(StatusCode.UNSUPPORTED_MEDIA_TYPE, mediaTypeTarget)
.build();
To configure how to handle urls that are not found use the interface, gateway.notFound(..)
for both Target
and
RestTarget
. The regex must be specified which will be used to determine which Resource
or RestResouce
to execute.
This allows applications to have many ways to react to a not found url based on the url regex.
Configuring otter is done by implementing Configure. The implementation instructs otter how to:
- Set cookie configuration for CSRF, Session, and other cookies
- Set CSRF signature key
- Set the status code when CSRF fails
- Set the template when CSRF fails
- Set Session encryption signature key
- Set the status code when the Session is not present
- Set the template when the Session is not present
- Read and Write chunk sizes - use for async i/o.
- Route requests to Resources
- Route requests to RestResources
- Group Resources together to use the same Session and User
- Group RestResources together to use the same User
- Handle Errors
Have a look a the hello world application for an example.
An otter application needs to extend OtterEntryServlet. This is needed to route requests from the servlet conatiner to otter.
It must override the following:
@Override
public Configure makeConfigure() {
return new AppConfig(new AppFactory());
}
makeConfigure()
must return your configure
implementation.
Have a look a the hello world application for an example.
Otter runs in a Jetty powered embedded servlet container. The port, document root, and the request log are all configurable.
Have a look a the hello world application for an example
Otter is able to compress the response body with gzip
. To enable it then add the MimeTypes
to compress in the Server
implementation.
With the hello world project:
$ curl -H "Content-Type: application/json; charset=utf-8" -H "Accept-Encoding: gzip" -i http://localhost:8080/rest/v2/hello
HTTP/1.1 200 OK
Date: Sat, 24 Aug 2019 13:03:54 GMT
Vary: Accept-Encoding, User-Agent
Content-Encoding: gzip
Content-Length: 55
O?S?N??O?T?jW&?#
Otter supports CSRF protection by implementing the double submit strategy.
Here is an example of how to protect a login page:
In the configure implementation:
Target<TokenSession, User> login = new TargetBuilder<TokenSession, User>()
.groupName(WEB_SITE_GROUP)
.form()
.resource(new LoginResource())
.regex(LoginResource.URL)
.build();
gateway.add(login);
In the Login Resource set the csrf challenge token to the appropriate ivar in the login presenter.
LoginPresenter presenter = new LoginPresenter("", request.getCsrfChallenge().get());
Render the CSRF challenge token on the page.
<input id="csrfToken" type="hidden" name="csrfToken" value="${presenter.getCsrfChallengeToken()}" / >
Done, it is CSRF protected.
Here is an example of how to protect an API. The use case is when javascript in a browser wants to call an API that is protected.
- Browser calls a URI backed by a
Resource
that is CSRF protected,text/html
. - Place the
csrfToken
into a meta tag. See OSWAP's recommendation
<meta name="csrf-token" content="${presenter.getCsrfChallengeToken()}">
- Then use javascript to read the value and send it in the header,
X-CSRF
.
Configuration.
RestTarget<TokenSession, ApiUser, Hello> helloCsrfApiV2 = new RestTargetBuilder<TokenSession, ApiUser, Hello>()
.groupName(API_GROUP_V2)
.method(Method.GET)
.restResource(new HelloCsrfRestResource())
.regex(HelloCsrfRestResource.URL)
.csrf() // <-- csrf protects all methods.
.authenticate()
.contentType(json)
.payload(Hello.class)
.build();
To pass it will need the CSRF cookie and and the header, X-CSRF
.
When a CSRF fails then Otter will return:
- status code of 403
- remove csrf cookie
This behavior can be overridden by implementing a halt bifunction and passing it into it's group.
Group<TokenSession, User> webSiteGroup = new GroupBuilder<TokenSession, User>()
.name(WEB_SITE_GROUP)
.sessionClazz(TokenSession.class)
.before(Label.AUTH_OPTIONAL, new AuthOptBetween())
.before(Label.AUTH_REQUIRED, new AuthBetween())
.onHalt(Halt.CSRF, (Response<TokenSession> response, HaltException e) -> {
response.setTemplate(Optional.of("/WEB-INF/jsp/403.jsp"));
response.setStatusCode(StatusCode.FORBIDDEN);
return response;
})
.onError(StatusCode.SERVER_ERROR, serverErrorResource)
.build();
Custom implementations of CSRF can be injected into Groups and RestGroups
Implement two betweens that handle preparing and protecting CSRF then inject them with the before
interface.
Group<TokenSession, User> webSiteGroup = new GroupBuilder<TokenSession, User>()
.name(WEB_SITE_GROUP)
.sessionClazz(TokenSession.class)
.before(Label.AUTH_OPTIONAL, new AuthOptBetween())
.before(Label.AUTH_REQUIRED, new AuthBetween())
.before(Label.CSRF_PROTECT, csrfProtect) // <-- custom csrf protect
.before(Label.CSRF_PREPARE, csrfPrepare) // <-- custom csrf prepare
.onError(StatusCode.SERVER_ERROR, serverErrorResource)
.build();
These would then be used instead of the defaults for all the targets in that group.
Implement one betweens that handle protecting CSRF then inject it with the before
interface.
BadRequestResource badRequestResource = new BadRequestResource();
ServerErrorResource serverErrorResource = new ServerErrorResource();
RestGroup<DummySession, ApiUser> apiGroupV3 = new RestGroupBuilder<DummySession, ApiUser>()
.name(API_GROUP_V3)
.sessionClazz(DefaultSession.class)
.before(Label.AUTH_OPTIONAL, authRestBetween)
.before(Label.AUTH_REQUIRED, authRestBetween)
.before(Label.CSRF_PROTECT, csrfProtect) // <-- custom csrf protect
.onError(StatusCode.BAD_REQUEST, badRequestResource, BadRequestPayload.class)
.onError(StatusCode.SERVER_ERROR, serverErrorResource, ServerErrorPayload.class)
.build();
It would then be used instead of the default for all the restTargets in that restGroup.
Files that are placed in, src/main/webapp/public
are public as long as they pass the entry filter regex.
For example, src/main/webapp/public/assets/js/jquery-3.3.1.min.js
can be retrieved from, assets/js/jquery-3.3.1.min.js