001package com.pingidentity.ds.plugin;
002
003import com.unboundid.directory.sdk.common.api.MonitorProvider;
004import com.unboundid.directory.sdk.common.operation.UpdatableBindResult;
005import com.unboundid.directory.sdk.common.operation.UpdatableSimpleBindRequest;
006import com.unboundid.directory.sdk.common.types.ActiveOperationContext;
007import com.unboundid.directory.sdk.common.types.RegisteredMonitorProvider;
008import com.unboundid.directory.sdk.ds.api.Plugin;
009import com.unboundid.directory.sdk.ds.config.PluginConfig;
010import com.unboundid.directory.sdk.ds.types.DirectoryServerContext;
011import com.unboundid.directory.sdk.ds.types.PreParsePluginResult;
012import com.unboundid.ldap.sdk.*;
013import com.unboundid.util.ByteString;
014import com.unboundid.util.args.ArgumentException;
015import com.unboundid.util.args.ArgumentParser;
016import com.unboundid.util.args.IntegerArgument;
017import com.unboundid.util.args.StringArgument;
018import com.yubico.client.v2.VerificationResponse;
019import com.yubico.client.v2.YubicoClient;
020import com.yubico.client.v2.exceptions.YubicoValidationFailure;
021import com.yubico.client.v2.exceptions.YubicoVerificationException;
022
023import java.util.ArrayList;
024import java.util.Arrays;
025import java.util.List;
026import java.util.concurrent.atomic.AtomicLong;
027
028public class YubiOnSimpleBind extends Plugin {
029    public static final String ARG_CLIENT_ID_NAME = "client-id";
030    public static final String ARG_CLIENT_SECRET_NAME = "client-secret";
031    public static final String ARG_PUBLIC_ID_ATTR_NAME = "public-id-attribute";
032    public static final String ARG_PUBLIC_ID_ATTR_DEFAULT = "ds-auth-yubikey-public-id";
033    public static final int YUBI_OTP_LENGTH = 44;
034    DirectoryServerContext serverContext;
035    PluginConfig config;
036    private RegisteredMonitorProvider registeredMonitorProvider;
037    private Integer yubiClientID;
038    private String yubiClientSecret;
039    private String yubiPublicIDAttribute;
040    private YubicoClient yubiClient;
041    AtomicLong evaluatedRequests;
042    AtomicLong processedRequests;
043    AtomicLong successfulRequests;
044
045
046
047    @Override
048    public String getExtensionName() {
049        return "ds-plugin-yubi-on-simple-bind";
050    }
051
052    @Override
053    public String[] getExtensionDescription() {
054        return new String[]{"Provides support for authenticating with Yubikey One-Time Passwords over Simple BIND Requests.",
055        "To enable this extension, you must first enable the Yubikey SASL Mechanism<br/>\n" +
056                "<pre>\n" +
057                "dsconfig set-sasl-mechanism-handler-prop \n" +
058                "    --handler-name UNBOUNDID-YUBIKEY-OTP \n" +
059                "    --set yubikey-client-id:12345 \n" +
060                "    --set yubikey-api-key:CHANGEME \n" +
061                "    --set enabled:true\n" +
062                "</pre>",
063        "Once this pre-requisite is met, you can enable the plugin with:\n" +
064                "<pre>\n" +
065                "dsconfig create-plugin \n" +
066                "    --plugin-name yubi-on-simple-bind  \n" +
067                "    --type third-party  \n" +
068                "    --set enabled:false  \n" +
069                "    --set plugin-type:preparsebind  \n" +
070                "    --set invoke-for-internal-operations:false  \n" +
071                "    --set extension-class:com.pingidentity.ds.plugin.YubiOnSimpleBind  \n" +
072                "    --set extension-argument:client-id=12345  \n" +
073                "    --set extension-argument:client-secret=CHANGEME\n" +
074                "</pre>",
075        "You may register a key with the register-yubikey-otp-device, like:\n" +
076                "<pre>\n" +
077                "register-yubikey-otp-device \n" +
078                "   --authenticationID u:user.0 \n" +
079                "   --userPassword password \n" +
080                "   --otp SOMEOTPHERE\n" +
081                "</pre>",
082        "Finally you can try out that authentication now requires yubikey otp for users with registered devices:<br/>\n" +
083                "<pre>\n" +
084                "bin/ldapsearch \n" +
085                "    -D uid=user.0,ou=People,dc=example,dc=com \n" +
086                "    -w passwordSOMEOTPHERE \n" +
087                "    -b uid=user.0,ou=People,dc=example,dc=com \n" +
088                "    -s base \n" +
089                "    '(&)'\n" +
090                "</pre>",
091        "NOTE: On PingDirectoryProxy, it is necessary to specify the public-id-attribute argument for which the user has read rights in the back-end server."};
092    }
093
094    @Override
095    public void defineConfigArguments(ArgumentParser parser) throws ArgumentException {
096        IntegerArgument yubiClientArg = new IntegerArgument(null, ARG_CLIENT_ID_NAME, true, 1, "{"+ ARG_CLIENT_ID_NAME +"}", "The YubiCo client ID");
097        parser.addArgument(yubiClientArg);
098
099        StringArgument yubiSecretArg = new StringArgument(null, ARG_CLIENT_SECRET_NAME, true, 1, "{"+ARG_CLIENT_SECRET_NAME+"}", "The secret key for the client");
100        parser.addArgument(yubiSecretArg);
101
102        StringArgument yubiPublicAttrArg = new StringArgument(null, ARG_PUBLIC_ID_ATTR_NAME,false,1,"{"+ARG_PUBLIC_ID_ATTR_NAME+"}","The name of the attribute to ", ARG_PUBLIC_ID_ATTR_DEFAULT);
103        parser.addArgument(yubiPublicAttrArg);
104    }
105
106    @Override
107    public ResultCode applyConfiguration(PluginConfig config, ArgumentParser parser, List<String> adminActionsRequired, List<String> messages) {
108        this.config = config;
109        yubiClientID = parser.getIntegerArgument(ARG_CLIENT_ID_NAME).getValue();
110        yubiClientSecret = parser.getStringArgument(ARG_CLIENT_SECRET_NAME).getValue();
111        yubiPublicIDAttribute =parser.getStringArgument(ARG_PUBLIC_ID_ATTR_NAME).getValue();
112        yubiClient = YubicoClient.getClient(yubiClientID, yubiClientSecret);
113        return ResultCode.SUCCESS;
114    }
115
116    @Override
117    public void initializePlugin(DirectoryServerContext serverContext, PluginConfig config, ArgumentParser parser) throws LDAPException {
118        this.serverContext = serverContext;
119        List<String> adminActionMessages = new ArrayList<>(3);
120        List<String> messages = new ArrayList<>(3);
121        ResultCode resultCode = applyConfiguration(config, parser, adminActionMessages, messages);
122        if (!ResultCode.SUCCESS.equals(resultCode)) {
123            adminActionMessages.forEach(s -> System.out.println(s));
124            messages.forEach(s -> System.out.println(s));
125        }
126        evaluatedRequests  = new AtomicLong(0);
127        processedRequests  = new AtomicLong(0);
128        successfulRequests = new AtomicLong(0);
129        registeredMonitorProvider = serverContext.registerMonitorProvider(new YubiOnSimpleBindMonitorProvider(),config);
130    }
131
132    @Override
133    public void finalizePlugin() {
134        if ( registeredMonitorProvider != null)
135            serverContext.deregisterMonitorProvider(registeredMonitorProvider);
136    }
137
138    @Override
139    public PreParsePluginResult doPreParse(ActiveOperationContext operationContext, UpdatableSimpleBindRequest request, UpdatableBindResult result) {
140        PreParsePluginResult fastInterrupt = new PreParsePluginResult(false, false, true, true);
141
142        if (request == null ){
143            result.setResultCode(ResultCode.UNWILLING_TO_PERFORM);
144            result.setDiagnosticMessage("No request received");
145            return fastInterrupt;
146        }
147
148        ByteString pwdByteString = request.getPassword();
149        if ( pwdByteString == null ){
150            result.setResultCode(ResultCode.INVALID_CREDENTIALS);
151            result.setDiagnosticMessage("no password received");
152            return fastInterrupt;
153        }
154
155        String pwdString = pwdByteString.stringValue();
156        if ( pwdString.isEmpty() ){
157            result.setResultCode(ResultCode.INVALID_CREDENTIALS);
158            result.setDiagnosticMessage("Received empty password");
159            return fastInterrupt;
160        }
161
162        if (pwdString.length() < 44 ){
163            result.setResultCode(ResultCode.INVALID_CREDENTIALS);
164            result.setDiagnosticMessage("Received password is too short to contain the user password followed by a Yubikey OTP");
165            return fastInterrupt;
166        }
167
168        byte[] pwdByteArray = pwdByteString.getValue();
169        byte[] yubiOTPByteArray = Arrays.copyOfRange(pwdByteArray, pwdByteArray.length - YUBI_OTP_LENGTH, pwdByteArray.length);
170        String yubiOTPString = new String(yubiOTPByteArray);
171        if ( ! YubicoClient.isValidOTPFormat(yubiOTPString) ){
172            result.setResultCode(ResultCode.INVALID_CREDENTIALS);
173            result.setDiagnosticMessage("Invalid Yubikey OTP format");
174            return fastInterrupt;
175        }
176
177        try {
178            VerificationResponse yubiResponse = yubiClient.verify(yubiOTPString);
179            if ( ! yubiResponse.isOk() ){
180                result.setResultCode(ResultCode.INVALID_CREDENTIALS);
181                result.setDiagnosticMessage(String.valueOf(yubiResponse.getStatus()));
182                return fastInterrupt;
183            }
184        } catch (YubicoVerificationException e) {
185            result.setResultCode(ResultCode.OTHER);
186            result.setDiagnosticMessage(e.getMessage());
187            e.printStackTrace();
188            return fastInterrupt;
189        } catch (YubicoValidationFailure yubicoValidationFailure) {
190            result.setResultCode(ResultCode.OTHER);
191            result.setDiagnosticMessage(yubicoValidationFailure.getMessage());
192            yubicoValidationFailure.printStackTrace();
193            return fastInterrupt;
194        }
195
196        try {
197            SearchResultEntry entry;
198
199            if ( serverContext.isDirectoryProxyFunctionalityAvailable() ) {
200                entry = operationContext.getInternalUserConnection().getEntry(request.getDN(), yubiPublicIDAttribute);
201            } else {
202                entry = operationContext.getInternalRootConnection().getEntry(request.getDN(),yubiPublicIDAttribute);
203            }
204
205            if ( entry == null ) {
206                result.setResultCode(ResultCode.INVALID_CREDENTIALS);
207                return fastInterrupt;
208            }
209            Attribute publicIDAttribute = entry.getAttribute(yubiPublicIDAttribute);
210            if (publicIDAttribute == null || !publicIDAttribute.hasValue()){
211                result.setResultCode(ResultCode.INVALID_CREDENTIALS);
212                return fastInterrupt;
213            }
214            String submittedPublicID = YubicoClient.getPublicId(yubiOTPString);
215            // multiple yubikeys might be registered for a user
216            Boolean publicIDFound = Boolean.FALSE;
217            for ( String registeredPublicID: publicIDAttribute.getValues() ){
218                if (submittedPublicID.equals(registeredPublicID)){
219                    publicIDFound=Boolean.TRUE;
220                    break;
221                }
222            }
223            if ( ! publicIDFound ){
224                result.setResultCode(ResultCode.INVALID_CREDENTIALS);
225                result.setDiagnosticMessage("Yubikey not registered with user");
226                return fastInterrupt;
227            }
228
229        } catch (LDAPException e) {
230            result.setResultData(e);
231            return fastInterrupt;
232        }
233
234        byte[] simplePasswordByteArray = Arrays.copyOfRange(pwdByteArray, 0, pwdByteArray.length - YUBI_OTP_LENGTH);
235        String simplePasswordString = new String(simplePasswordByteArray);
236
237        request.setPassword(simplePasswordString);
238        return PreParsePluginResult.SUCCESS;
239    }
240
241    class YubiOnSimpleBindMonitorProvider extends MonitorProvider {
242        YubiOnSimpleBindMonitorProvider(){
243        }
244
245        @Override
246        public String getExtensionName() {
247            return "ds-plugin-yubi-on-simple-bind-monitor-provider";
248        }
249
250        @Override
251        public String[] getExtensionDescription() {
252            return null;
253        }
254
255        @Override
256        public String getMonitorInstanceName() {
257            return config.getConfigObjectName()+"-monitor";
258        }
259
260        @Override
261        public String getMonitorObjectClass() {
262            return "ds-yubi-monitor-entry";
263        }
264
265        @Override
266        public List<Attribute> getMonitorAttributes() {
267            List<Attribute> attributes = new ArrayList<>(3);
268            attributes.add(new Attribute("requests-evaluated", evaluatedRequests.toString()));
269            attributes.add(new Attribute("requests-processed", processedRequests.toString()));
270            attributes.add(new Attribute("requests-successful", successfulRequests.toString()));
271            return attributes;
272        }
273    }
274}