Monday, April 18, 2011

Apache Shiro Part 2 - Realms, Database and PGP Certificates

This is second part of series dedicated to Apache Shiro. We started previous part with simple unsecured web application. When we finished, the application had basic authentication and authorization. Users could log in and log out. All web pages and buttons had access rights assigned and enforced. Both authorization and authentication data have been stored in static configuration file.

As we promised in the end of last part, we will move user account data to database. In addition, we will give users an option to authenticate themselves via PGP certificates. As a result, our application will have multiple alternative log in options: log in with user name/password and log in with certificate. We will finish by turning alternative log in options mandatory.

In other words, we will show how to create custom realm and how to handle multi-realm scenario. We will create three different versions of SimpleShiroSecuredApplication:

Each version has test class RunWaitTest. The class starts web server with application deployed at http://localhost:9180/simpleshirosecuredapplication/ url.

Note: We updated previous part since first release. The most notable change is new section which shows how to add error message to login page. Thanks everybody for feedback.

Realms

First, we explain what realms are and how to create them. If you are not interested in theory, proceed to next chapter.

Realms are responsible for authentication and authorization. Any time user wants to log in to the application, authentication information is collected and passed to realm. Realm verifies supplied data and decides whether user should be allowed to log in, have access to resource or own specific role.

Authentication information has two parts:
  • principal - represents account unique identifier e.g. user name, account id, PGP certificate, ...
  • credential - proves users identity e.g. password, PGP certificate, fingerprint, ... .

Shiro provides realms able to read authorization data from active directory, ldap, ini file, properties file and database. Realms are configured in main section of Shiro.ini file:
realmName=org.apache.shiro.realm.jdbc.JdbcRealm

Authentication
All realms implement Realm interface. Two interface methods are important: supports and getAuthenticationInfo. Both receive principal and credentials inside authentication token object.

Supports method decides whether realm is able to authenticate user based on supplied authentication token. For example, if my realm checks user name and password, it rejects authentication token with X509 certificate only.

Method getAuthenticationInfo performs authentication itself. If principal and credential from authentication token represents valid log in information, the method returns authentication info object. otherwise, the realm returns null.

Authorization
If the realm wishes to do also authorization, it has to implement Authorizer interface. Each Authorizer method takes principal as parameter and checks either role(s) or permission(s). It is important to understand, that the realm obtains all authorization requests, even if they came from user authenticated by another realm. Of course, realm may decide to ignore any authorization request.

Permissions are supplied either as strings or as permission objects. Unless you have strong reason to do otherwise, use WildcardPermissionResolver to convert strings into permission objects.

Other Options
Shiro framework investigates realms at run time for additional interfaces. If the realm implements them, it can use:

These features are available to any realm that implements additional interface. No other configuration is necessary.

Custom Realms
The easiest way to create new realm is to extend either AuthenticatingRealm or AuthorizingRealm class. They have reasonable implementation of all useful interfaces mentioned in previous section. If they are not usable for your needs, you can extend CachingRealm or create new realm from scratch.

Move to Database

Current version of SimpleShiroSecuredApplication uses default realm for both authentication and authorization. Default realm - IniRealm reads user account information from configuration file. Such storage is acceptable only for simplest applications. Anything slightly more complex needs to have credentials stored in better persistent storage.

New requirements: account credentials and access rights are stored in database. Stored passwords are hashed and salted.

In this chapter, we will connect application to database and create tables to store all user account data. Then, we will replace IniRealm with realm able to read from database and salt passwords.

Database Infrastructure
The section describes sample application infrastructure. It contains no information about Shiro, so you can freely skip it.

Example application uses Apache Derby database in embedded mode.

We use Liquibase for database deployment and upgrades. It is open source library for tracking, managing and applying database changes. Database changes (new tables, new columns, foreign keys) are stored in database changelog file. Upon start up, Liquibase investigates database and apply all new changes. As a result, database is always consistent and up to date with no real effort on our part.

Add dependency to Derby and Liquibase into SimpleShiroSecuredApplication pom.xml:
<dependency>
    <groupid>org.apache.derby</groupid>
    <artifactid>derby</artifactid>
    <version>10.7.1.1</version>
</dependency>
<dependency>
    <groupid>org.liquibase</groupid>
    <artifactid>liquibase-core</artifactid>
    <version>2.0.1</version>
</dependency>

Add jndi to jetty:
<dependency>
   <groupid>org.mortbay.jetty</groupid>
   <artifactid>jetty-naming</artifactid>
   <version>${jetty.version}</version>
   <scope>test</scope>
</dependency>  
<dependency>
   <groupid>org.mortbay.jetty</groupid>
   <artifactid>jetty-plus</artifactid>
   <version>${jetty.version}</version>
   <scope>test</scope>
</dependency>  

Create db.changelog.xml file with database structure description. It creates tables where users, roles and permissions are stored. It also fills those tables with initial data. We used random_salt_value_username as salt and following method to create hashed salted passwords:
public static String simpleSaltedHash(String username, String password) {
   Sha256Hash sha256Hash = new Sha256Hash(password, (new SimpleByteSource("random_salt_value_" + username)).getBytes());
  String result = sha256Hash.toHex();

   System.out.println(username + " simple salted hash: " + result);
   return result;
}

Create data source pointing to derby in WEB-INF/jetty-web.xml file:
<configure class="org.mortbay.jetty.webapp.WebAppContext" id="SimpleShiroSecuredApplication">
 <new class="org.mortbay.jetty.plus.naming.Resource" id="SimpleShiroSecuredApplication">
  <arg>jdbc/SimpleShiroSecuredApplicationDB</arg>
  <arg>
   <new class="org.apache.derby.jdbc.EmbeddedDataSource">
    <set name="DatabaseName">../SimpleShiroSecuredApplicationDatabase</set>
    <set name="createDatabase">create</set>
   </new>
  </arg>
 </new>
</configure>

Configure datasource and liquibase in web.xml file:
<resource-ref>
  <description>Derby Connection</description>
  <res-ref-name>jdbc/SimpleShiroSecuredApplicationDB</res-ref-name>
  <res-type>javax.sql.DataSource</res-type>
  <res-auth>Container</res-auth>
</resource-ref>
 
<context-param>
  <param-name>liquibase.changelog</param-name>
  <param-value>src/main/resources/db.changelog.xml</param-value>
</context-param>

<context-param>
  <param-name>liquibase.datasource</param-name>
  <param-value>jdbc/SimpleShiroSecuredApplicationDB</param-value>
</context-param>

<listener>
  <listener-class>
    liquibase.integration.servlet.LiquibaseServletListener
  </listener-class>
</listener>

Finally, jetty configured to read jetty-web.xml with enabled jndi is in AbstractContainerTest class.

Create New Realm
Shiro provided JDBCRealm is able to do both authentication and authorization. It uses configurable SQL queries to read user names, passwords, permissions and roles from database. Unfortunately, the realm has two shortcomings:
  • It is not able to load data source from JNDI (open issue).
  • It is not able to salt passwords (open issue).

We extend it and create new class JNDIAndSaltAwareJdbcRealm. As all properties are configurable in ini file, new property jndiDataSourceName will be automatically configurable too. The realm looks up data source in JNDI whenever new property is set:
protected String jndiDataSourceName;

public String getJndiDataSourceName() {
 return jndiDataSourceName;
}

public void setJndiDataSourceName(String jndiDataSourceName) {
 this.jndiDataSourceName = jndiDataSourceName;
 this.dataSource = getDataSourceFromJNDI(jndiDataSourceName);
}

private DataSource getDataSourceFromJNDI(String jndiDataSourceName) {
 try {
  InitialContext ic = new InitialContext();
  return (DataSource) ic.lookup(jndiDataSourceName);
 } catch (NamingException e) {
  log.error("JNDI error while retrieving " + jndiDataSourceName, e);
  throw new AuthorizationException(e);
 }
}

Method doGetAuthenticationInfo reads account authentication information from the database and converts it into authentication info object. It returns null if no account information is found. Parent class AuthenticatingRealm compares authentication info object with original user supplied data.

We override doGetAuthenticationInfo to read both password hash and salt from database and store them in authentication info object:
doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
 ...
 // read password hash and salt from db 
 PasswdSalt passwdSalt = getPasswordForUser(username);
 ...
 // return salted credentials
 SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(username, passwdSalt.password, getName());
 info.setCredentialsSalt(new SimpleByteSource(passwdSalt.salt));

 return info;
}
The example here contains only most important piece of code. Full class is available on Github.

Configure New Realm
Configure realm and jndi name in Shiro.ini file:
[main] 
# realm to be used
saltedJdbcRealm=org.meri.simpleshirosecuredapplication.realm.JNDIAndSaltAwareJdbcRealm
# any object property is automatically configurable in Shiro.ini file
saltedJdbcRealm.jndiDataSourceName=jdbc/SimpleShiroSecuredApplicationDB 
# the realm should handle also authorization
saltedJdbcRealm.permissionsLookupEnabled=true

Configure SQL queries:
# If not filled, subclasses of JdbcRealm assume "select password from users where username = ?"
# first result column is password, second result column is salt 
saltedJdbcRealm.authenticationQuery = select password, salt from sec_users where name = ?
# If not filled, subclasses of JdbcRealm assume "select role_name from user_roles where username = ?"
saltedJdbcRealm.userRolesQuery = select role_name from sec_users_roles where user_name = ?
# If not filled, subclasses of JdbcRealm assume "select permission from roles_permissions where role_name = ?"
saltedJdbcRealm.permissionsQuery = select permission from sec_roles_permissions where role_name = ?

JdbcRealm uses credetials matcher exactly the same way as IniRealm was:
# password hashing specification
sha256Matcher = org.apache.shiro.authc.credential.HashedCredentialsMatcher
sha256Matcher.hashAlgorithmName=SHA-256
saltedJdbcRealm.credentialsMatcher = $sha256Matcher

NOTE: we removed sections [users] and [roles] from configuration file. Otherwise, Shiro would use both IniRealm and JdbcRealm. This would create multi-realm scenario which is out of scope of this chapter.

From user point of view, application works exactly the same way as before. He can log in to the same user accounts as before. However, user names, passwords, salts, permissions and roles are now stored in database.

Full source code is available in 'authentication_stored_in_database' branch on Github.

Alternative Login - Certificates

Some systems allow multiple authentication means for user log in. For example, user may supply user name/password, log in with Google account, Facebook account or anything else. We will add something similar to our simple application. We will give our users option to authenticate themselves with PGP certificates.

New requirements: application supports PGP certificates as alternative authentication mechanism. Login screen shows up only if user does not possess valid certificate associated with application account. If the user has valid known PGP certificate, he is logged automatically.

When user tries to log in to the application, he has to supply authentication data. Those data are captured by servlet filter. Filter converts data to authentication token and pass the token to realms. If any realm wish to authenticate user, it converts authentication token to authentication info object. If the realm does not wish to do so, then it returns null.

Out of the box Shiro framework filters ignores PGP certificates in request. Available authentication tokens are not able to hold them and realms are not aware of PGP certificates at all. Therefore, we have to create:

  • authentication token to move certificate around,
  • servlet filter able to read certificates,
  • realm to validate certificates and match them to user accounts.

Our application will have two different realms. One uses names to identify accounts and passwords to authenticate users, other uses PGP certificates to do both.

Before we start coding, we have to deal with PGP certificates and infrastructure that surrounds our application. If you are not interested in PGP certificates set up, jump over next section.

Infrastructure
When user visits web application, his web browser may send copy of PGP certificate to web server. The certificate is signed either by some certificate authority or by the certificate itself (self-signed certificate). Web server keeps list of certificates it trusts to in storage called truststore. If the truststore contains either users certificate or certificate of authority that signed it, then web server trusts users certificate. Trusted certificates are passed to the application.

We will:
  • create certificate for each user,
  • create truststore,
  • configure web server,
  • associate certificates with user accounts.

Create and manage certificates in portecle. Sample certificates for SimpleShiroSecuredApplication are located in src\test\resources\clients directory. All stores and certificates have common password 'secret'.
Create Certificate
Create self-signed certificate for each user in portecle:
  • Create new jks keystore: File -> New Keystore, select jks.
  • Generate new certificate: Tools -> Generate Key Pair. Leave password fields empty, certificate will inherit password from keystore.
  • Export public certificate: select new certificate -> right click -> Export, select Head Certificate. This creates .cer file.
  • Export private key and certificate: select new certificate -> right click -> Export, select Private Key and Certificates. This creates .p12 file.

.cer file contains only public certificate, so you can give it to anybody. On the other hand, .p12 file contains users private key and must be kept secret. Distribute it only to user (e.g. import it to your browser for testing).
Create Truststore
Create new truststore and import public certificate .cer files to it:
  • File -> New Keystore, select jks.
  • Tools -> Import Trusted Certificate.
Configure Web Server
Web server has to ask for certificates and validate them against truststore. It is not possible to ask for certificate from Java. Each web server is configured differently. Jetty configuration is available in Look at AbstractContainerTest class on Github.
Associate Certificates with Accounts
Each certificate is uniquely identified by serial number and name of certificate authority that signed it. We store them together with user names and passwords in database table. Database changes are in db.changelog.xml file, see changeset 3 for new columns and changeset 4 for data initialization.

Authentication Token
Authentication token represents user data and credentials during an authentication attempt. It must implement authentication token interface and holds whatever data we wish to pass between servlet filter and realm.

As we wish to use both user names/passwords and certificates for authentication, we extend UsernamePasswordToken class and add certificate property to it. New authentication token X509CertificateUsernamePasswordToken implements new interface X509CertificateAuthenticationToken and both are available on Github:
public class X509CertificateUsernamePasswordToken extends UsernamePasswordToken implements X509CertificateAuthenticationToken {

    private X509Certificate certificate;

    @Override
    public X509Certificate getCertificate() {
      return certificate;
    }

    public void setCertificate(X509Certificate certificate) {
      this.certificate = certificate;
    }

}

Servlet Filter
Shiro filter converts user data to authentication tokens. Up to now, we used FormAuthenticationFilter. If incoming request comes from logged user, the filter lets user in. If the user is trying to authenticate himself, the filter creates authentication token and pass it to the framework. Otherwise it redirects user to the login screen.

Our filter CertificateOrFormAuthenticationFilter extends FormAuthenticationFilter.

First, we have to convince it that not only requests with user name and passwords, but also any request with PGP certificate can be considered a log in attempt. Second, we have to modify the filter to send PGP certificate along with user name and password in an authentication token.

The method isLoginSubmission determines whether request represents authentication attempt:
@Override
    protected boolean isLoginSubmission(ServletRequest request, ServletResponse response) {
        return super.isLoginSubmission(request, response) || isCertificateLogInAttempt(request, response);
    }
    
    private boolean isCertificateLogInAttempt(ServletRequest request, ServletResponse response) {
        return hasCertificate(request) && !getSubject(request, response).isAuthenticated();
    }
    
    private boolean hasCertificate(ServletRequest request) {
        return null != getCertificate(request);
    }
    
    private X509Certificate getCertificate(ServletRequest request) {
        X509Certificate[] attribute = (X509Certificate[]) request.getAttribute("javax.servlet.request.X509Certificate");
        return attribute==null? null : attribute[0];
    }

The method createToken creates authentication token:
@Override
    protected AuthenticationToken createToken(String username, String password, ServletRequest request, ServletResponse response) {
        boolean rememberMe = isRememberMe(request);
        String host = getHost(request);
        X509Certificate certificate = getCertificate(request);
        return createToken(username, password, rememberMe, host, certificate);
    }
    
    protected AuthenticationToken createToken(String username, String password, boolean rememberMe, String host, X509Certificate certificate) {
        return new X509CertificateUsernamePasswordToken(username, password, rememberMe, host, certificate);
    }

Replace FormAuthenticationFilter with CertificateOrFormAuthenticationFilter filter in configuration file:
[main]
# filter configuration
certificateFilter = org.meri.simpleshirosecuredapplication.servlet.CertificateOrFormAuthenticationFilter
# specify login page
certificateFilter.loginUrl = /simpleshirosecuredapplication/account/login.jsp
# name of request parameter with username; if not present filter assumes 'username'
certificateFilter.usernameParam = user
# name of request parameter with password; if not present filter assumes 'password'
certificateFilter.passwordParam = pass
# does the user wish to be remembered?; if not present filter assumes 'rememberMe'
certificateFilter.rememberMeParam = remember
# redirect after successful login
certificateFilter.successUrl  = /simpleshirosecuredapplication/account/personalaccountpage.jsp

Redirect all URLs to new filter:
[urls]
# force ssl for login page 
/simpleshirosecuredapplication/account/login.jsp=ssl[8443], certificateFilter

# only users with some roles are allowed to use role-specific pages 
/simpleshirosecuredapplication/repairmen/**=certificateFilter, roles[repairman]
/simpleshirosecuredapplication/sales/**=certificateFilter, roles[sales]
/simpleshirosecuredapplication/scientists/**=certificateFilter, roles[scientist]
/simpleshirosecuredapplication/adminarea/**=certificateFilter, roles[Administrator]

# enable certificateFilter filter for all application pages
/simpleshirosecuredapplication/**=certificateFilter

Custom Realm
Our new realm will be responsible only for authentication. Authorization (access rights) will be handled by JNDIAndSaltAwareJdbcRealm. Such configuration works as long as PGP certificate authenticates user into the same account as user name/password would. Otherwise said, primary principal returned by new realm must be the same as primary principal returned by JNDIAndSaltAwareJdbcRealm.

Our realm does not need caching nor any other service provided by optional interfaces. Therefore, we have to implement only two interfaces: Realm and Nameable.

X509CertificateRealm supports only authentication tokens with PGP certificate:
@Override
 public boolean supports(AuthenticationToken token) {
  if (token!=null)
   return  token instanceof X509CertificateAuthenticationToken;

  return false;
 }

The method getAuthentcationInfo is responsible for authentication. If supplied certificate is valid and associated with an user account, the realm creates authentication info object. Remember that primary principal must be the same as the one returned by JNDIAndSaltAwareJdbcRealm:
@Override
public AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
    // the cast is legal, since Shiro will let in only X509CertificateAuthenticationToken tokens
    X509CertificateAuthenticationToken certificateToken = (X509CertificateAuthenticationToken) token;
    X509Certificate certificate = certificateToken.getCertificate();
    
    // verify certificate
    if (!certificateOK(certificate)) {
        return null;
    }
    
    // the issuer name and serial number uniquely identifies certificate
    BigInteger serialNumber = certificate.getSerialNumber();
    String issuerName = certificate.getIssuerDN().getName();
    
    // find account associated with certificate
    String username = findUsernameToCertificate(issuerName, serialNumber);
    if (username == null) {
        // return null as no account was found
        return null;
    }
    
    // sucesfull verification, return authentication info
    return new SimpleAuthenticationInfo(username, certificate, getName());
}

Note that the realm has two new properties: trustStore and trustStorePassword. Both are needed for PGP certificate validation. As any other property, both are configurable in configuration file.

Add new realm to Shiro.ini file:
[main]
certificateRealm = org.meri.simpleshirosecuredapplication.realm.X509CertificateRealm
certificateRealm.trustStore=src/main/resources/truststore
certificateRealm.trustStorePassword=secret

It is now possible to log in to the application with PGP certificate. If the certificate is not available, user name and password works too.

Application source code is available in 'certificates_as_alternative_log_in_method' branch on Github.

Multiple Realms

If configuration file contains more than one realm, all of them are used. In such case, Shiro tries to authenticate user with all configured realms and authentication results are merged together. The object responsible for merging is called authentication strategy. Framework provides three authentication strategies:

By default, 'at least one successful strategy' is used, which suits our purposes well. Again, it is possible to create custom authentication strategy. For example, we may require user to present both PGP certificate and user name/password credentials to log in.

New requirements: user has to present both PGP certificate and user name/password credentials to log in.

In other words, we need strategy that:
  • fails if some realm does not support token,
  • fails if some realm does not authenticate user,
  • fails if two realms authenticate different principals.

Authentication strategy is an object that implements authentication strategy interface. The interface methods are called after and before authentication attempts. We create 'primary principal same authentication strategy' from 'all successful strategy', closest strategy available. We compare principals after each realm authentication attempt:
@Override
public AuthenticationInfo afterAttempt(...) {
    validatePrimaryPrincipals(info, aggregate, realm);
    return super.afterAttempt(realm, token, info, aggregate, t);
}

private void validatePrimaryPrincipals(...) {
     ...

    Object aggregPrincipal = aggregPrincipals.getPrimaryPrincipal();
    Object infoPrincipal = infoPrincipals.getPrimaryPrincipal();
    if (!aggregPrincipal.equals(infoPrincipal)) {
        String message = "All realms are required to return the same primary principal. Offending realm: " + realm.getName();
        log.debug(message);
        throw new AuthenticationException(message);
    }
}

Authentication strategy is configured in Shiro.ini file:
# multi-realms strategy
authenticationStrategy=org.meri.simpleshirosecuredapplication.authc.PrimaryPrincipalSameAuthenticationStrategy
securityManager.authenticator.authenticationStrategy = $authenticationStrategy

Finaly, we have to change isLoginSubmission method of CertificateOrFormAuthenticationFilter back. Only requests with user name and password are now considered log in attempts. Certificate is not sufficient anymore:
@Override
protected boolean isLoginSubmission(ServletRequest request, ServletResponse response) {
  return super.isLoginSubmission(request, response);
}

If you run the application now, both certificate and user name/password log in methods are mandatory.

This version is available in 'certificates_as_mandatory_log_in_method' branch on Github.

End

This part was dedicated to Shiro realms. We created three different application versions all of them are available on Github. They covered basic and probably most important realm features.

If you need to know more, start with classes linked here and read their javadocs. They are well written and extensive.

The next part is dedicated to Apache Shiro cryptography package.

15 comments:

Les Hazlewood said...

Once again, great stuff Meri!

Thanks so much for sharing with us. I've added this to the Shiro articles page as well!

Cheers,

Les

Meri said...

Hi Les,

thank you for the link (and compliments as well :)) )

Meri

Byron said...

Hi Meri,

Thanks for the great articles. Your solutions work very well. Love your articles and love Shiro.

Byron

Meri said...

Hi Byron,

thank you :). Yeah, Shiro is great :)).

Meri

Mike Warren said...

Thanks - the information about how to implement Salted JDBC Realm was just what I needed.

Anonymous said...

Thank you It so hard to fine good documentation. The examples are easy to understand. Great job.

Anonymous said...

Many thanks, your blog helped me find a solution to using DB2 and Shiro after the standard DB2 JDBC driver failed to work with Shiro. Great document!

Anonymous said...

Great article!

But X.509 certificate is X.509 certificate and PGP/GPG is alternate crypto systems with different trust model.
so x.509 != pgp certificate

blurblurNick said...

Hi Meri,

Great article..
Do you mind to share a little on shiro authenticate against ActiveDirectory or LDAP?
Currently my web app works greatly by authenticating against the database through jdbc but having some difficulty to authenticate against AD or LDAP. Need your advices.

Thanks in advances.

Best regards,
Nick

nuno santos said...

Hi Meri, I'm getting an SSL Handshake error, it says that the remote host closed the connection. It happens after I import the certificates to the browser. Any idea why?

Meri said...

@nuno santos: That looks like some configuration problem, probably on the server side.

chiyoko said...

hi, meri...
i get this error, when try to run using mvn jetty:run

[INFO] Starting jetty 6.1.25 ...
2013-08-29 13:56:46.928:INFO::jetty-6.1.25
2013-08-29 13:56:47.359:WARN::Configuration problem at Derby Connectionjdbc/SimpleShiroSecuredApplicationDBjavax.sql.Dat
aSourceContainer: java.lang.IllegalStateException: No
thing to bind for name javax.sql.DataSource/default
2013-08-29 13:56:47.361:WARN::Failed startup of context org.mortbay.jetty.plugin.Jetty6PluginWebAppC
ontext@14b87d31{/,G:\RnDACL\SimpleShiroSecuredApplication_authentication_stored_in_database\src\main
\webapp}
javax.servlet.UnavailableException: Configuration problem
at org.mortbay.jetty.webapp.WebXmlConfiguration.initialize(WebXmlConfiguration.java:299)
at org.mortbay.jetty.plus.webapp.AbstractConfiguration.initialize(AbstractConfiguration.java
:133)

Anonymous said...

Hi,

The post is really helpful been new to shiro.
Currently I have a use case in which I do Ldap authetication using customRealm.It works all fine but I started facing issue for authorization.
To make it short I want to do Ldap authetion using customRealm and authorization using TextConfigurationrealm but I shld be able to assign role to the LDAP user and should not be hardcoding the ldap pwd anywhere.

Any help is appreciated.

Thanks in advance.

Anonymous said...

How can you do it base on LDAP?

John said...

How can you do it base on LDAP?

Post a Comment