1 19 20 package com.sslexplorer.activedirectory; 21 22 import java.net.URI ; 23 import java.net.URISyntaxException ; 24 import java.security.PrivilegedAction ; 25 import java.util.ArrayList ; 26 import java.util.Collection ; 27 import java.util.Collections ; 28 import java.util.HashSet ; 29 import java.util.Hashtable ; 30 import java.util.Iterator ; 31 import java.util.List ; 32 import java.util.Map ; 33 import java.util.Properties ; 34 import java.util.StringTokenizer ; 35 36 import javax.naming.Context ; 37 import javax.naming.NamingException ; 38 import javax.naming.ldap.InitialLdapContext ; 39 import javax.security.auth.Subject ; 40 import javax.security.auth.login.LoginContext ; 41 import javax.security.auth.login.LoginException ; 42 43 import org.apache.commons.logging.Log; 44 import org.apache.commons.logging.LogFactory; 45 46 import com.sslexplorer.boot.PropertyList; 47 import com.sslexplorer.properties.Property; 48 import com.sslexplorer.properties.impl.realms.RealmKey; 49 import com.sslexplorer.realms.Realm; 50 import com.sslexplorer.security.UserDatabaseException; 51 52 public final class ActiveDirectoryUserDatabaseConfiguration { 53 private static final Log logger = LogFactory.getLog(ActiveDirectoryUserDatabaseConfiguration.class); 54 private static final String COMMON_NAME = "CN="; 55 private static final String CN_USERS = COMMON_NAME + "Users"; 56 private static final String CN_BUILTIN = COMMON_NAME + "Builtin"; 57 private static final String LDAP_PROTOCOL = "ldap://"; 58 private static final String PORT_SEPARATOR = ":"; 59 private static final String ESCAPE_BACKSLASH = "\\\\"; 60 private static final String ESCAPE_QUOTE = "\""; 61 private static final String [] ESCAPED_CHARACTERS = {ESCAPE_BACKSLASH, "/", "#", ",,", "\\+", ESCAPE_QUOTE, "<", ">", ";"}; 62 private static final int SSL_SECURED_PORT = 636; 63 private static final int CLEAR_TEXT_PORT = 389; 64 65 66 public static final String GSSAPI_AUTHENTICATION_METHOD = "GSSAPI"; 67 68 public static final String SIMPLE_AUTHENTICATION_METHOD = "simple"; 69 70 71 public static final String PLAIN_PROTOCOL = "plain"; 72 73 public static final String SSL_PROTOCOL = "ssl"; 74 75 private final Realm realm; 76 private final ActiveDirectoryPropertyManager propertyManager; 77 private String domain; 78 private String controllerHost; 79 private Collection <String > backupControllerHosts; 80 private String serviceAuthenticationType; 81 private String userAuthenticationType; 82 private String serviceAccountName; 83 private String serviceAccountPassword; 84 private String baseDn; 85 private String protocolType; 86 private boolean followReferrals; 87 private int userCacheSize; 88 private int groupCacheSize; 89 private boolean inMemoryCache; 90 private int timeToLive; 91 private final List <String > includedOuBasesList = new ArrayList <String >(); 92 private final List <String > excludedOuBasesList = new ArrayList <String >(); 93 private boolean hasFilteredOus; 94 private boolean usernamesAreCaseSensitive; 95 private final Collection <URI > activeDirectoryUrls = new ArrayList <URI >(); 96 private URI lastContactedActiveDirectoryUrl; 97 private int pageSize; 98 private int timeOut; 99 private PagedResultTemplate template; 100 101 public ActiveDirectoryUserDatabaseConfiguration(Realm realm, Properties propertyNames) throws IllegalArgumentException , Exception { 102 this.realm = realm; 103 propertyManager = new ActiveDirectoryPropertyManager(realm); 104 initialize(propertyNames); 105 } 106 107 private void initialize(Properties propertyNames) throws Exception { 108 setControllerHost(getProperty("activeDirectory.controllerHost", propertyNames)); 110 setBackupControllerHosts(getPropertyList("activeDirectory.backupControllerHosts", propertyNames)); 111 setDomain(getProperty("activeDirectory.domain", propertyNames)); 112 setUserAuthenticationType(getProperty("activeDirectory.userAuthenticationType", propertyNames)); 113 setServiceAccountName(getProperty("activeDirectory.serviceAccountUsername", propertyNames)); 114 setServiceAccountPassword(getProperty("activeDirectory.serviceAccountPassword", propertyNames)); 115 116 setFollowReferrals(Boolean.valueOf(System.getProperty("sslexplorer.followADReferrals", "false"))); 117 setUserCacheSize(getPropertyInt("activeDirectory.cacheUserMaxObjects", propertyNames)); 118 setGroupCacheSize(getPropertyInt("activeDirectory.cacheGroupMaxObjects", propertyNames)); 119 setInMemoryCache(getPropertyBoolean("activeDirectory.cacheInMemory", propertyNames)); 120 setTimeToLive(getPropertyInt("activeDirectory.userCacheTTL", propertyNames)); 121 122 Collection <String > includedOuFilterList = getPropertyList("activeDirectory.organizationalUnitFilter", propertyNames); 123 Collection <String > excludedOuFilterList = getPropertyList("activeDirectory.excludedOrganizationalUnitFilter", propertyNames); 124 setValidOus(baseDn, includedOuFilterList, excludedOuFilterList); 125 setIncludeStandardUsers(getPropertyBoolean("activeDirectory.includeStandardUsers", propertyNames)); 126 setIncludeBuiltInGroups(getPropertyBoolean("activeDirectory.includeBuiltInGroups", propertyNames)); 127 128 setUsernamesAreCaseSensitive(getPropertyBoolean("activeDirectory.usernamesAreCaseSensitive", propertyNames)); 129 setPageSize(getPropertyInt("activeDirectory.pageSize", propertyNames)); 130 setTimeOut(getPropertyInt("activeDirectory.connection.timeout", propertyNames)); 131 } 132 133 private String getProperty(String key, Properties propertyNames) { 134 return Property.getProperty(getRealmKey(key, propertyNames)); 135 } 136 137 private boolean getPropertyBoolean(String key, Properties propertyNames) { 138 return Property.getPropertyBoolean(getRealmKey(key, propertyNames)); 139 } 140 141 private int getPropertyInt(String key, Properties propertyNames) { 142 return Property.getPropertyInt(getRealmKey(key, propertyNames)); 143 } 144 145 private Collection <String > getPropertyList(String key, Properties propertyNames) { 146 return Property.getPropertyList(getRealmKey(key, propertyNames)); 147 } 148 149 private RealmKey getRealmKey(String key, Properties propertyNames) { 150 String propertyOrDefault = propertyNames.getProperty(key, key); 151 return new RealmKey(propertyOrDefault, realm); 152 } 153 154 void postInitialize() throws URISyntaxException { 155 setActiveDirectoryUrls(); 156 if(!isServiceAuthenticationGssApi() && !serviceAccountName.toLowerCase().endsWith(baseDn)) { 157 serviceAccountName = appendBaseDn(formatUsername(serviceAccountName)); 158 } 159 includedOuBasesList.removeAll(excludedOuBasesList); 161 Collection <String > escapedIncludedOuBasesList = getEscapedDns(includedOuBasesList, false); 162 Collection <String > escapedExcludedOuBasesList = getEscapedDns(excludedOuBasesList, false); 163 Collection <String > escapedOuSearchBase = getEscapedDns(includedOuBasesList, true); 164 template = new PagedResultTemplate(escapedIncludedOuBasesList, escapedExcludedOuBasesList, escapedOuSearchBase, pageSize); 165 refresh(); 166 } 167 168 private static Collection <String > getEscapedDns(Collection <String > toEscapeDns, boolean requiresSecondEscape) { 169 Collection <String > escapedDns = new HashSet <String >(toEscapeDns.size()); 170 for (String toEscapeDn : toEscapeDns) { 171 String escapedDn = getEscapedDn(toEscapeDn, requiresSecondEscape); 172 escapedDns.add(escapedDn); 173 } 174 return Collections.unmodifiableCollection(escapedDns); 175 } 176 177 static String getEscapedDn(String toEscape, boolean requiresSecondEscape) { 178 for (int index = 0; index < ESCAPED_CHARACTERS.length; index++) { 179 String character = ESCAPED_CHARACTERS[index]; 180 if (requiresSecondEscape) { 181 if(character.equals(ESCAPE_BACKSLASH)) { 182 toEscape = toEscape.replaceAll(character, ESCAPE_BACKSLASH + ESCAPE_BACKSLASH + ESCAPE_BACKSLASH + ESCAPE_BACKSLASH); 183 } else if(character.equals(ESCAPE_QUOTE)) { 184 toEscape = toEscape.replaceAll(character, ESCAPE_BACKSLASH + ESCAPE_BACKSLASH + ESCAPE_QUOTE); 185 } else { 186 toEscape = toEscape.replaceAll(character, ESCAPE_BACKSLASH + character); 187 } 188 } else { 189 toEscape = toEscape.replaceAll(character, ESCAPE_BACKSLASH + character); 190 } 191 } 192 return toEscape; 193 } 194 195 private static String formatUsername(String username) { 196 return username.toUpperCase().startsWith(COMMON_NAME) ? username : COMMON_NAME + username; 197 } 198 199 public String appendBaseDn(String commonName) { 200 if (commonName.toLowerCase().endsWith(baseDn.toLowerCase())) { 201 return commonName; 202 } else { 203 return commonName.endsWith(",") ? commonName : commonName + "," + baseDn; 204 } 205 } 206 207 void refresh() { 208 propertyManager.refresh(); 209 } 210 211 public String getDomain() { 212 return domain; 213 } 214 215 private void setDomain(String domain) throws Exception { 216 this.domain = domain.toUpperCase().trim(); 217 if (this.domain.equals("")) { 218 throw new IllegalArgumentException ("No active directory domain configured."); 219 } 220 setBaseDn(splitDomain(domain)); 221 } 222 223 private void setControllerHost(String controllerHost) { 224 if (controllerHost.equals("")) { 225 throw new IllegalArgumentException ("No active directory controller host configured."); 226 } 227 this.controllerHost = controllerHost; 228 } 229 230 private Collection <String > getBackupControllerHosts() { 231 return backupControllerHosts; 232 } 233 234 private void setBackupControllerHosts(Collection <String > backupControllerHosts) { 235 this.backupControllerHosts = backupControllerHosts; 236 } 237 238 String getServiceAuthenticationType() { 239 return serviceAuthenticationType; 240 } 241 242 public void setServiceAuthenticationType(String serviceAuthenticationType) { 243 this.serviceAuthenticationType = serviceAuthenticationType; 244 } 245 246 boolean isServiceAuthenticationGssApi() { 247 return GSSAPI_AUTHENTICATION_METHOD.equals(getServiceAuthenticationType()); 248 } 249 250 boolean isUserAuthenticationGssApi() { 251 return GSSAPI_AUTHENTICATION_METHOD.equals(getUserAuthenticationType()); 252 } 253 254 String getUserAuthenticationType() { 255 return userAuthenticationType; 256 } 257 258 public void setUserAuthenticationType(String userAuthenticationType) { 259 this.userAuthenticationType = userAuthenticationType; 260 } 261 262 String getServiceAccountName() { 263 return serviceAccountName; 264 } 265 266 void setServiceAccountName(String serviceAccountName) { 267 this.serviceAccountName = serviceAccountName == null ? null : serviceAccountName.trim(); 268 } 269 270 private String getServiceAccountPassword() { 271 return serviceAccountPassword; 272 } 273 274 void setServiceAccountPassword(String serviceAccountPassword) { 275 this.serviceAccountPassword = serviceAccountPassword; 276 } 277 278 private String getProtocolType() { 279 return protocolType; 280 } 281 282 public boolean isSslProtcolType() { 283 return "ssl".equals(getProtocolType()); 284 } 285 286 public void setProtocolType(String protocolType) { 287 this.protocolType = protocolType; 288 } 289 290 private boolean isFollowReferrals() { 291 return followReferrals; 292 } 293 294 void setFollowReferrals(boolean followReferrals) { 295 this.followReferrals = followReferrals; 296 } 297 298 public String getBaseDn() { 299 return baseDn; 300 } 301 302 void setBaseDn(String baseDn) { 303 this.baseDn = baseDn == null ? null : baseDn.toLowerCase().trim(); 304 } 305 306 void setUserCacheSize(int userCacheSize) { 307 this.userCacheSize = userCacheSize; 308 } 309 310 void setGroupCacheSize(int groupCacheSize) { 311 this.groupCacheSize = groupCacheSize; 312 } 313 314 void setInMemoryCache(boolean inMemoryCache) { 315 this.inMemoryCache = inMemoryCache; 316 } 317 318 int getTimeToLive() { 319 return timeToLive; 320 } 321 322 void setTimeToLive(int timeToLive) { 323 if (timeToLive < 1) { 324 logger.warn("Cache TTL is less than 1 minute. This would cause serious performance problems. The minimum value of 1 minute will now be used"); 325 timeToLive = 1; 326 } 327 this.timeToLive = minutesToMillis(timeToLive); 328 } 329 330 private static int minutesToMillis(int minutes) { 331 return minutes * 60 * 1000; 332 } 333 334 UserContainer createUserContainer() { 335 return new UserContainer(userCacheSize, inMemoryCache, isUsernamesAreCaseSensitive()); 336 } 337 338 GroupContainer createRoleContainer() { 339 return new GroupContainer(groupCacheSize, inMemoryCache); 340 } 341 342 void setValidOus(String baseDn, Collection <String > includedOuFilterList, Collection <String > excludedOuFilterList) { 343 includedOuBasesList.clear(); 344 includedOuBasesList.addAll(getFormattedOuFilterList(baseDn, includedOuFilterList)); 345 excludedOuBasesList.clear(); 346 excludedOuBasesList.addAll(getFormattedOuFilterList(baseDn, excludedOuFilterList)); 347 includedOuBasesList.removeAll(excludedOuBasesList); 348 349 hasFilteredOus = !includedOuBasesList.isEmpty(); 350 if (!hasFilteredOus) { 351 includedOuBasesList.add(baseDn); 352 } 353 354 if (logger.isDebugEnabled()) { 355 logger.debug("Included OU Bases:"); 356 for (String dn : includedOuBasesList) { 357 logger.debug(" " + dn); 358 } 359 360 logger.debug("Excluded OU Bases:"); 361 for (String dn : excludedOuBasesList) { 362 logger.debug(" " + dn); 363 } 364 } 365 } 366 367 private static Collection <String > getFormattedOuFilterList(String baseDn, Collection <String > ouFilterList) { 368 Collection <String > formattedOuFilterList = new HashSet <String >(); 369 for (String dn : ouFilterList) { 370 if (!dn.trim().toLowerCase().endsWith(baseDn.trim().toLowerCase())) { 371 dn = dn + "," + baseDn; 372 } 373 formattedOuFilterList.add(dn); 374 } 375 return formattedOuFilterList; 376 } 377 378 private void setIncludeStandardUsers(boolean includeStandardUsers) { 379 if (includeStandardUsers) { 380 if (hasFilteredOus) { 381 includedOuBasesList.add(0, appendBaseDn(CN_USERS)); 382 } 383 } else { 384 excludedOuBasesList.add(0, appendBaseDn(CN_USERS)); 385 } 386 } 387 388 private void setIncludeBuiltInGroups(boolean includeBuiltInGroups) { 389 if (includeBuiltInGroups) { 390 if (hasFilteredOus) { 391 includedOuBasesList.add(0, appendBaseDn(CN_BUILTIN)); 392 } 393 } else { 394 excludedOuBasesList.add(0, appendBaseDn(CN_BUILTIN)); 395 } 396 } 397 398 boolean isUsernamesAreCaseSensitive() { 399 return usernamesAreCaseSensitive; 400 } 401 402 private void setUsernamesAreCaseSensitive(boolean usernamesAreCaseSensitive) { 403 this.usernamesAreCaseSensitive = usernamesAreCaseSensitive; 404 } 405 406 boolean isDnValid(String dn) { 407 return template.isDnValid(dn); 408 } 409 410 private void setActiveDirectoryUrls() throws URISyntaxException { 411 activeDirectoryUrls.clear(); 412 lastContactedActiveDirectoryUrl = null; 413 414 int controllerPort = getControllerPort(); 415 URI primaryUri = controllerHost.contains(PORT_SEPARATOR) ? buildURI(controllerHost) : buildURI(controllerHost, controllerPort); 416 activeDirectoryUrls.add(primaryUri); 417 418 for (String uri : getBackupControllerHosts()) { 419 if (uri.contains(PORT_SEPARATOR)) { 420 activeDirectoryUrls.add(buildURI(uri)); 421 } else { 422 activeDirectoryUrls.add(buildURI(uri, controllerPort)); 423 } 424 } 425 426 setLastContactedActiveDirectoryUrl(primaryUri); 427 } 428 429 private int getControllerPort() { 430 int indexOf = controllerHost.indexOf(PORT_SEPARATOR); 431 if(indexOf == -1 || indexOf == controllerHost.length() - 1) { 432 if(isServiceAuthenticationGssApi()) { 433 return CLEAR_TEXT_PORT; 434 } 435 return isSslProtcolType() ? SSL_SECURED_PORT : CLEAR_TEXT_PORT; 436 } else { 437 String port = controllerHost.substring(indexOf + 1); 438 Integer valueOf = Integer.valueOf(port); 439 return valueOf; 440 } 441 } 442 443 private static URI buildURI(String host, int port) throws URISyntaxException { 444 return buildURI(host + PORT_SEPARATOR + port); 445 } 446 447 private static URI buildURI(String url) throws URISyntaxException { 448 return new URI (LDAP_PROTOCOL + url); 449 } 450 451 String getContactableActiveDirectories() { 452 URI lastContactedUrl = getLastContactedActiveDirectoryUrl(); 453 URI firstUrl = activeDirectoryUrls.isEmpty() ? null : activeDirectoryUrls.iterator().next(); 454 boolean isDifferent = !lastContactedUrl.equals(firstUrl); 455 456 Collection <URI > hosts = new ArrayList <URI >(activeDirectoryUrls.size() + 1); 457 if (isDifferent) { 458 hosts.add(lastContactedUrl); 459 } 460 hosts.addAll(activeDirectoryUrls); 461 return getHosts(hosts); 462 } 463 464 private static String getHosts(Collection <URI > urls) { 465 StringBuffer buffer = new StringBuffer (); 466 for (Iterator itr = urls.iterator(); itr.hasNext();) { 467 URI url = (URI ) itr.next(); 468 buffer.append(url.toString()); 469 if(itr.hasNext()) { 470 buffer.append(" "); 471 } 472 } 473 return buffer.toString(); 474 } 475 476 synchronized URI getLastContactedActiveDirectoryUrl() { 477 return lastContactedActiveDirectoryUrl; 478 } 479 480 synchronized void setLastContactedActiveDirectoryUrl(String url) { 481 try { 482 setLastContactedActiveDirectoryUrl(new URI (url)); 483 } catch (URISyntaxException e) { 484 } 486 } 487 488 private synchronized void setLastContactedActiveDirectoryUrl(URI url) { 489 if (lastContactedActiveDirectoryUrl == null || !lastContactedActiveDirectoryUrl.equals(url)) { 490 PropertyList kerbrosControllerSettings = getKerbrosControllerSettings(url); 491 propertyManager.refresh(Collections.singletonMap("activeDirectory.backupControllerHosts", kerbrosControllerSettings.getAsPropertyText())); 492 } 493 lastContactedActiveDirectoryUrl = url; 494 } 495 496 private PropertyList getKerbrosControllerSettings(URI contactedUrl) { 497 PropertyList values = new PropertyList(); 498 values.add(getKerbrosController(contactedUrl)); 499 500 for (URI url : activeDirectoryUrls) { 501 String kerbrosController = getKerbrosController(url); 502 if (!values.contains(kerbrosController)) { 503 values.add(kerbrosController); 504 } 505 } 506 return values; 507 } 508 509 private static String getKerbrosController(URI url) { 510 String toParse = url.toString(); 511 return toParse.substring(LDAP_PROTOCOL.length(), toParse.lastIndexOf(PORT_SEPARATOR)); 512 } 513 514 void search(InitialLdapContext context, String filter, String [] attributes, PagedResultMapper mapper) throws Exception { 515 template.search(context, filter, attributes, mapper); 516 } 517 518 void setPageSize(int pageSize) { 519 this.pageSize = pageSize; 520 } 521 522 private int getTimeout() { 523 return timeOut; 524 } 525 526 private void setTimeOut(int timeOut) { 527 this.timeOut = timeOut * 1000; 528 } 529 530 public Object doAs(PrivilegedAction action) throws UserDatabaseException { 531 Object result = null; 532 if (isServiceAuthenticationGssApi()) { 533 try { 534 LoginContext context = getServiceAccountLoginContext(); 535 result = Subject.doAs(context.getSubject(), action); 536 logoutContext(context); 537 } catch (Exception e) { 538 logger.error("Failure to create Login Context", e); 539 throw new UserDatabaseException("", e); 540 } 541 } else { 542 result = action.run(); 543 } 544 545 if (result instanceof Throwable ) { 546 Throwable e = (Throwable ) result; 547 logger.error("Failure to doAs", e); 548 throw new UserDatabaseException("", e); 549 } 550 return result; 551 } 552 553 private LoginContext getServiceAccountLoginContext() throws Exception { 554 558 try { 559 return createLoginContext(getServiceAccountName(), getServiceAccountPassword()); 560 } catch (LoginException e) { 561 Throwable cause = e.getCause(); 562 if (cause != null && cause.getClass().getName().equals("sun.security.krb5.KrbException")) { 564 throw new Exception ("Failed to logon. Please check your Active Directory configuration.", e); 565 } 566 throw e; 567 } 568 } 569 570 LoginContext createLoginContext(String username, String password) throws LoginException { 571 if (logger.isDebugEnabled()) { 572 logger.debug("Creating login context for " + username); 573 } 574 575 UserPasswordCallbackHandler callbackHandler = new UserPasswordCallbackHandler(); 576 callbackHandler.setUserId(username); 577 callbackHandler.setPassword(password); 578 579 LoginContext context = new LoginContext (ActiveDirectoryUserDatabase.class.getName(), callbackHandler); 580 context.login(); 581 return context; 582 } 583 584 static void logoutContext(LoginContext context) { 585 try { 586 if (context != null) { 587 context.logout(); 588 } 589 } catch (LoginException e) { 590 } 592 } 593 594 InitialLdapContext getAuthenticatedContext(String url, Map <String , String > properties) throws NamingException { 595 Hashtable <String , String > variables = new Hashtable <String , String >(properties); 596 variables.put(Context.SECURITY_AUTHENTICATION, getServiceAuthenticationType()); 597 if (!isServiceAuthenticationGssApi()) { 598 variables.put(Context.SECURITY_PRINCIPAL, getServiceAccountName()); 599 variables.put(Context.SECURITY_CREDENTIALS, getServiceAccountPassword()); 600 } 601 return getInitialContext(url, variables); 602 } 603 604 InitialLdapContext getAuthenticatedContext(String url) throws NamingException { 605 return getAuthenticatedContext(url, Collections.<String , String > emptyMap()); 606 } 607 608 public InitialLdapContext getInitialContext(String url, Map <String , String > properties) throws NamingException { 609 Hashtable <String , String > variables = new Hashtable <String , String >(properties); 610 variables.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory"); 611 variables.put(Context.PROVIDER_URL, url); 613 if (isSslProtcolType()) { 614 variables.put("java.naming.ldap.factory.socket", "com.sslexplorer.boot.CustomSSLSocketFactory"); 615 } 617 618 if (isFollowReferrals()) { 619 variables.put(Context.REFERRAL, "follow"); 620 } 621 622 variables.put("com.sun.jndi.ldap.connect.timeout", String.valueOf(getTimeout())); 623 variables.put("java.naming.ldap.version", "3"); 624 variables.put("com.sun.jndi.ldap.connect.pool", "true"); 625 variables.put("javax.security.sasl.qop", "auth-conf,auth-int,auth"); 626 variables.put(Context.SECURITY_PROTOCOL, getProtocolType()); 627 628 InitialLdapContext context = new InitialLdapContext (variables, null); 629 String usedUrl = (String ) context.getEnvironment().get(Context.PROVIDER_URL); 630 setLastContactedActiveDirectoryUrl(usedUrl); 631 return context; 632 } 633 634 private static String splitDomain(String domain) { 635 StringBuffer buffer = new StringBuffer (); 636 for (StringTokenizer tokenizer = new StringTokenizer (domain, "."); tokenizer.hasMoreTokens();) { 637 if (buffer.length() > 0) { 638 buffer.append(","); 639 } 640 buffer.append("DC=" + tokenizer.nextToken()); 641 } 642 return buffer.toString(); 643 } 644 } | Popular Tags |