Commit cfb18f02 authored by Victor Näslund's avatar Victor Näslund

Added project

parents
# Shibboleth-TOTP-loginhandler
This project add a TOTP loginhandler for shibboleth-idp
The TOTP secret is stored encrypted in LDAP
## How to use
First install maven and java 1.8 devel.
Then create the file /opt/shibboleth-idp/credentials/totp-configfile
both on your current compiling node and the shibboleth-idp node
```bash
# Lines are in this format type:value
# Values are encoded in base64
#
# FirstPartOfTOTPAESKey and SecondPartOfTOTPAESKey
# will be used for the encryption key for the TOTP secrets stored in LDAP
# Fore more info about the encryption see TOTPAES.java
# Change them to something appropriate
#
# TOTPMaxTries is how many times one can try
# before the module start taking anti bruteforce measures
#
# TOTPThrottleTime is how many seconds the account and/or ip address
# will be locked because of anti bruteforce measures
#
# To encode the values to base64 use this
# echo -n "BBB" | base64
# Will give you QkJC
#
# To decode base64 do this
# echo "QkJC" | base64 -d
FirstPartOfTOTPAESKey:QUFB
SecondPartOfTOTPAESKey:QkJC
TOTPMaxTries:OA==
TOTPThrottleTime:MTIwMA==
```
To compile the module run
```bash
mvn clean install
```
This will give you a jar file
```bash
totp-0.99.jar
```
Copy this file to your shibboleth-idp installation like
```bash
cp totp-0.99.jar ${shibboleth-idp}/edit-webapp/WEB-INF/lib/
```
Rebuild shibboleth-idp with the module using
```bash
cd ${shibboleth-idp}
./bin/build.sh
```
### Ok now we simply have to edit some config files to enable the module
In ${shibboleth-idp}/conf/logback.xml we have this
```xml
<!-- Logs IdP, but not OpenSAML, messages -->
<logger name="net.shibboleth.idp" level="INFO"/>
```
Add this just below
```xml
<!-- TOTP set to DEBUG if needed -->
<logger name="se.smhi.totp" level="INFO"/>
```
In ${shibboleth-idp}/conf/authn/general-authn.xml we have this
```xml
<bean id="authn/Password" parent="shibboleth.AuthenticationFlow"
p:passiveAuthenticationSupported="true"
p:forcedAuthenticationSupported="true" />
```
Add this just below
```xml
<!-- TOTP -->
<bean id="authn/TOTP" parent="shibboleth.AuthenticationFlow"
p:passiveAuthenticationSupported="true"
p:forcedAuthenticationSupported="true">
<property name="supportedPrincipals">
<util:list>
<bean parent="shibboleth.SAML2AuthnContextClassRef"
c:classRef="urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken" />
</util:list>
</property>
</bean>
```
In ${shibboleth-idp}/conf/authn/ldap-authn-config.xml we have this line
```xml
<alias name="ValidateUsernamePasswordAgainstLDAP" alias="ValidateUsernamePassword" />
```
Add this line below it
```xml
<alias name="ValidateUsernamePasswordTOTPAgainstLDAP" alias="ValidateUsernamePasswordTOTP" />
```
In the file ${shibboleth-idp}/conf/ldap.properties we have idp.authn.LDAP.returnAttributes
Edit idp.authn.LDAP.returnAttributes by adding the name of your LDAP TOTP attribute here so shibboleth fetch it during authentication
Also edit TOTP to idp.authn.flows like
```bash
idp.authn.LDAP.returnAttributes = cn,TOTPAttribute
idp.authn.flows = Password|TOTP
```
In ${shibboleth-idp}/flows/authn/conditions/conditions-flow.xml we have this
```xml
<action-state id="ValidateUsernamePassword">
<!-- Call outs for exceptional conditions. -->
<transition on="AccountWarning" to="CallExpiringPassword" />
<transition on="ExpiringPassword" to="CallExpiringPassword" />
<transition on="ExpiredPassword" to="CallExpiredPassword" />
<transition on="AccountLocked" to="CallAccountLocked" />
<transition to="DisplayUsernamePasswordPage" />
</action-state>
```
Add this just below
```xml
<!-- TOTP -->
<action-state id="ValidateUsernamePasswordTOTP">
<!-- Call outs for exceptional conditions. -->
<transition on="AccountWarning" to="CallExpiringPassword" />
<transition on="ExpiringPassword" to="CallExpiringPassword" />
<transition on="ExpiredPassword" to="CallExpiredPassword" />
<transition on="AccountLocked" to="CallAccountLocked" />
<transition to="DisplayUsernamePasswordTOTPPage" />
</action-state>
```
In ${shibboleth-idp}/messages/authn-messages.properties add these lines
```bash
ThrottledUsername = throttled-username
ThrottledIPAddress = throttled-ipaddress
throttled-username.message = Your account is locked for XXX (see TOTPThrottle.java file) minutes
throttled-ipaddress.message = Your IP address is locked for XXX (see TOTPThrottle.java file) minutes
```
In the file ${shibboleth-idp}/system/conf/webflow-config.xml we have this line
```xml
<webflow:flow-location id="authn/Password" path="../system/flows/authn/password-authn-flow.xml" />
```
Add this below it
```xml
<!-- TOTP-->
<webflow:flow-location id="authn/TOTP" path="../system/flows/authn/totp-authn-flow.xml" />
```
Now copy the TOTP config files
```bash
cp system/flows/authn/totp-authn-beans.xml to ${shibboleth-idp}/system/flows/authn/totp-authn-beans.xml
cp system/flows/authn/totp-authn-flow.xml ${shibboleth-idp}/system/flows/authn/totp-authn-flow.xml
cp views/loginTOTP.vm ${shibboleth-idp}/views/loginTOTP.vm
cp conf/authn/totp-authn-config.xml ${shibboleth-idp}/conf/authn/totp-authn-config.xml
```
## Generate your TOTP secret
Uncomment the function call to generateSecretSaltIvForLDAP at the end of the function testValidateTOTPCode in TOTPTest.java then
```bash
mvn clean install
```
Edit your LDAP multivalue attribute and add the generated encrypted secret, salt and IV to your LDAP attribute
Add the TOTP secret to your TOTP device, for example the app Google Authenticator for Android/iPhone phones
## You should now be able to login using TOTP
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>se.smhi.totp</groupId>
<artifactId>totp</artifactId>
<packaging>jar</packaging>
<version>0.99</version>
<name>totp</name>
<url>http://smhi.se</url>
<properties>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>shib-release</id>
<url>https://build.shibboleth.net/nexus/content/groups/public</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>shib-snapshot</id>
<url>https://build.shibboleth.net/nexus/content/repositories/snapshots</url>
<releases>
<enabled>false</enabled>
</releases>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId>
<version>RELEASE</version>
</dependency>
<dependency>
<groupId>net.shibboleth.idp</groupId>
<artifactId>idp-authn-impl</artifactId>
<version>RELEASE</version>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>RELEASE</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>RELEASE</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
package se.smhi.totp;
import java.util.HashMap;
/**
* Encodes arbitrary byte arrays as case-insensitive base-32 strings
*
* @author sweis@google.com (Steve Weis)
* @author Neal Gafter
*/
public class Base32String {
// singleton
private static final Base32String INSTANCE =
new Base32String("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"); // RFC 4668/3548
static Base32String getInstance() {
return INSTANCE;
}
//32 alpha-numeric characters. Excluding 0, 1, O, and I
private String ALPHABET;
private char[] DIGITS;
private int MASK;
private int SHIFT;
private HashMap<Character, Integer> CHAR_MAP;
static final String SEPARATOR = "-";
protected Base32String(String alphabet) {
this.ALPHABET = alphabet;
DIGITS = ALPHABET.toCharArray();
MASK = DIGITS.length - 1;
SHIFT = Integer.numberOfTrailingZeros(DIGITS.length);
CHAR_MAP = new HashMap<Character, Integer>();
for (int i = 0; i < DIGITS.length; i++) {
CHAR_MAP.put(DIGITS[i], i);
}
}
public static byte[] decode(String encoded) throws DecodingException {
return getInstance().decodeInternal(encoded);
}
protected byte[] decodeInternal(String encoded) throws DecodingException {
// Remove whitespace and separators
encoded = encoded.trim().replaceAll(SEPARATOR, "").replaceAll(" ", "");
// Canonicalize to all upper case
encoded = encoded.toUpperCase();
if (encoded.length() == 0) {
return new byte[0];
}
int encodedLength = encoded.length();
int outLength = encodedLength * SHIFT / 8;
byte[] result = new byte[outLength];
int buffer = 0;
int next = 0;
int bitsLeft = 0;
for (char c : encoded.toCharArray()) {
if (!CHAR_MAP.containsKey(c)) {
throw new DecodingException("Illegal character: " + c);
}
buffer <<= SHIFT;
buffer |= CHAR_MAP.get(c) & MASK;
bitsLeft += SHIFT;
if (bitsLeft >= 8) {
result[next++] = (byte) (buffer >> (bitsLeft - 8));
bitsLeft -= 8;
}
}
// We'll ignore leftover bits for now.
//
// if (next != outLength || bitsLeft >= SHIFT) {
// throw new DecodingException("Bits left: " + bitsLeft);
// }
return result;
}
public static String encode(byte[] data) {
return getInstance().encodeInternal(data);
}
protected String encodeInternal(byte[] data) {
if (data.length == 0) {
return "";
}
// SHIFT is the number of bits per output character, so the length of the
// output is the length of the input multiplied by 8/SHIFT, rounded up.
if (data.length >= (1 << 28)) {
// The computation below will fail, so don't do it.
throw new IllegalArgumentException();
}
int outputLength = (data.length * 8 + SHIFT - 1) / SHIFT;
StringBuilder result = new StringBuilder(outputLength);
int buffer = data[0];
int next = 1;
int bitsLeft = 8;
while (bitsLeft > 0 || next < data.length) {
if (bitsLeft < SHIFT) {
if (next < data.length) {
buffer <<= 8;
buffer |= (data[next++] & 0xff);
bitsLeft += 8;
} else {
int pad = SHIFT - bitsLeft;
buffer <<= pad;
bitsLeft += pad;
}
}
int index = MASK & (buffer >> (bitsLeft - SHIFT));
bitsLeft -= SHIFT;
result.append(DIGITS[index]);
}
return result.toString();
}
@Override
// enforce that this class is a singleton
public Object clone() throws CloneNotSupportedException {
throw new CloneNotSupportedException();
}
public class DecodingException extends Exception {
public DecodingException(String message) {
super(message);
}
}
}
package se.smhi.totp;
import javax.annotation.Nonnull;
import javax.servlet.http.HttpServletRequest;
import net.shibboleth.idp.authn.AbstractExtractionAction;
import net.shibboleth.idp.authn.AuthnEventIds;
import net.shibboleth.idp.authn.context.AuthenticationContext;
import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
import net.shibboleth.utilities.java.support.component.ComponentSupport;
import net.shibboleth.utilities.java.support.logic.Constraint;
import net.shibboleth.utilities.java.support.primitive.StringSupport;
import org.opensaml.profile.action.ActionSupport;
import org.opensaml.profile.context.ProfileRequestContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* SMHI 2015-11-11 Victor Näslund <victor.naslund@smhi.se>
* Modified version of net.shibboleth.idp.authn.context.UsernamePasswordContext
* Added TOTP and throttling code
*/
/**
* An action that extracts a username and password from an HTTP form body or query string,
* creates a {@link UsernamePasswordContext}, and attaches it to the {@link AuthenticationContext}.
*
* @event {@link org.opensaml.profile.action.EventIds#PROCEED_EVENT_ID}
* @event {@link AuthnEventIds#NO_CREDENTIALS}
* @pre <pre>ProfileRequestContext.getSubcontext(AuthenticationContext.class, false) != null</pre>
* @post If getHttpServletRequest() != null, a pair of form or query parameters is
* extracted to populate a {@link UsernamePasswordContext}.
*/
public class ExtractUsernamePasswordTOTPFromFormRequest extends AbstractExtractionAction {
/** Class logger. */
@Nonnull private final Logger log = LoggerFactory.getLogger(ExtractUsernamePasswordTOTPFromFormRequest.class);
/** Parameter name for TOTPCode. */
@Nonnull @NotEmpty private String TOTPCodeFieldName;
/** Parameter name for username. */
@Nonnull @NotEmpty private String usernameFieldName;
/** Parameter name for password. */
@Nonnull @NotEmpty private String passwordFieldName;
/** Parameter name for SSO bypass. */
@Nonnull @NotEmpty private String ssoBypassFieldName;
/** Constructor. */
ExtractUsernamePasswordTOTPFromFormRequest() {
TOTPCodeFieldName = "TOTPCode";
usernameFieldName = "username";
passwordFieldName = "password";
ssoBypassFieldName = "donotcache";
}
// Handle input it should only be 6 digits
private boolean stringNotOnlyDigits(@Nonnull @NotEmpty final String TOTPCode) {
for (int i = 0; i < TOTPCode.length(); i++) {
if (!Character.isDigit(TOTPCode.charAt(i))) {
return true;
}
}
return false;
}
/**
* Set the TOTPCode parameter name.
*
* @param fieldName the TOTPCode parameter name
*/
public void setTOTPCodeFieldName(@Nonnull @NotEmpty final String fieldName) {
ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
TOTPCodeFieldName = Constraint.isNotNull(
StringSupport.trimOrNull(fieldName), "TOTPCode field name cannot be null or empty.");
}
/**
* Set the username parameter name.
*
* @param fieldName the username parameter name
*/
public void setUsernameFieldName(@Nonnull @NotEmpty final String fieldName) {
ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
usernameFieldName = Constraint.isNotNull(
StringSupport.trimOrNull(fieldName), "Username field name cannot be null or empty.");
}
/**
* Set the password parameter name.
*
* @param fieldName the password parameter name
*/
public void setPasswordFieldName(@Nonnull @NotEmpty final String fieldName) {
ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
passwordFieldName = Constraint.isNotNull(
StringSupport.trimOrNull(fieldName), "Password field name cannot be null or empty.");
}
/**
* Set the SSO bypass parameter name.
*
* @param fieldName the SSO bypass parameter name
*/
public void setSSOBypassFieldName(@Nonnull @NotEmpty final String fieldName) {
ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
ssoBypassFieldName = Constraint.isNotNull(
StringSupport.trimOrNull(fieldName), "SSO Bypass field name cannot be null or empty.");
}
/** {@inheritDoc} */
@Override
protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
@Nonnull final AuthenticationContext authenticationContext) {
final UsernamePasswordTOTPContext upCtx = authenticationContext
.getSubcontext(UsernamePasswordTOTPContext.class, true);
upCtx.setUsername(null);
upCtx.setPassword(null);
upCtx.setTOTPCode(null);
upCtx.setIPAddress(null);
final HttpServletRequest request = getHttpServletRequest();
if (request == null) {
log.debug("{} Profile action does not contain an HttpServletRequest", getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
return;
}
final String username = request.getParameter(usernameFieldName);
if (username == null || username.isEmpty()) {
log.debug("{} No username in request", getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
return;
}
upCtx.setUsername(applyTransforms(username));
final String password = request.getParameter(passwordFieldName);
if (password == null || password.isEmpty()) {
log.debug("{} No password in request", getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
return;
}
upCtx.setPassword(password);
// Get TOTP code
final String TOTPCode = request.getParameter(TOTPCodeFieldName);
if (TOTPCode == null || TOTPCode.isEmpty()
|| TOTPCode.length() != 6 || stringNotOnlyDigits(TOTPCode)) {
log.debug("{} No valid TOTP code in request", getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
return;
}
upCtx.setTOTPCode(TOTPCode);
// Get client IP
final String IPAddress = request.getRemoteAddr();
if (IPAddress == null || IPAddress.isEmpty()) {
log.debug("{} No IPAddress in request", getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
return;
}
upCtx.setIPAddress(IPAddress);
final String donotcache = request.getParameter(ssoBypassFieldName);
if (donotcache != null && "1".equals(donotcache)) {
log.debug("{} Recording do-not-cache instruction in authentication context", getLogPrefix());
authenticationContext.setResultCacheable(false);
}
}
}
package se.smhi.totp;
import java.io.ByteArrayInputStream;
import java.io.DataInput;
import java.io.DataInputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.security.GeneralSecurityException;
import javax.crypto.Mac;
/**
* An implementation of the HOTP generator specified by RFC 4226. Generates
* short passcodes that may be used in challenge-response protocols or as
* timeout passcodes that are only valid for a short period.
*
* The default passcode is a 6-digit decimal code and the default timeout
* period is 5 minutes.
*
* @author sweis@google.com (Steve Weis)
*
*/
public class PasscodeGenerator {
/** Default decimal passcode length */
private static final int PASS_CODE_LENGTH = 6;
/** Default passcode timeout period (in seconds) */
private static final int INTERVAL = 30;
/** The number of previous and future intervals to check */
private static final int ADJACENT_INTERVALS = 2;
private static final int PIN_MODULO =
(int) Math.pow(10, PASS_CODE_LENGTH);
private final Signer signer;
private final int codeLength;
private final int intervalPeriod;
/*
* Using an interface to allow us to inject different signature
* implementations.
*/
interface Signer {
byte[] sign(byte[] data) throws GeneralSecurityException;
}
/**
* @param mac A {@link Mac} used to generate passcodes
*/
public PasscodeGenerator(Mac mac) {
this(mac, PASS_CODE_LENGTH, INTERVAL);
}
/**
* @param mac A {@link Mac} used to generate passcodes
* @param passCodeLength The length of the decimal passcode
* @param interval The interval that a passcode is valid for
*/
public PasscodeGenerator(final Mac mac, int passCodeLength, int interval) {
this(new Signer() {
public byte[] sign(byte[] data){
re