001 //$HeadURL: svn+ssh://jwilden@svn.wald.intevation.org/deegree/base/branches/2.5_testing/src/org/deegree/tools/security/ActiveDirectoryImporter.java $ 002 /*---------------------------------------------------------------------------- 003 This file is part of deegree, http://deegree.org/ 004 Copyright (C) 2001-2009 by: 005 Department of Geography, University of Bonn 006 and 007 lat/lon GmbH 008 009 This library is free software; you can redistribute it and/or modify it under 010 the terms of the GNU Lesser General Public License as published by the Free 011 Software Foundation; either version 2.1 of the License, or (at your option) 012 any later version. 013 This library is distributed in the hope that it will be useful, but WITHOUT 014 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 015 FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 016 details. 017 You should have received a copy of the GNU Lesser General Public License 018 along with this library; if not, write to the Free Software Foundation, Inc., 019 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 020 021 Contact information: 022 023 lat/lon GmbH 024 Aennchenstr. 19, 53177 Bonn 025 Germany 026 http://lat-lon.de/ 027 028 Department of Geography, University of Bonn 029 Prof. Dr. Klaus Greve 030 Postfach 1147, 53001 Bonn 031 Germany 032 http://www.geographie.uni-bonn.de/deegree/ 033 034 e-mail: info@deegree.org 035 ----------------------------------------------------------------------------*/ 036 package org.deegree.tools.security; 037 038 import java.io.FileInputStream; 039 import java.io.IOException; 040 import java.io.PrintWriter; 041 import java.io.StringWriter; 042 import java.util.ArrayList; 043 import java.util.HashMap; 044 import java.util.Hashtable; 045 import java.util.Iterator; 046 import java.util.Properties; 047 048 import javax.naming.Context; 049 import javax.naming.NamingEnumeration; 050 import javax.naming.NamingException; 051 import javax.naming.directory.Attribute; 052 import javax.naming.directory.Attributes; 053 import javax.naming.directory.SearchControls; 054 import javax.naming.directory.SearchResult; 055 import javax.naming.ldap.Control; 056 import javax.naming.ldap.InitialLdapContext; 057 import javax.naming.ldap.LdapContext; 058 import javax.naming.ldap.PagedResultsControl; 059 import javax.naming.ldap.PagedResultsResponseControl; 060 061 import org.deegree.framework.mail.EMailMessage; 062 import org.deegree.framework.mail.MailHelper; 063 import org.deegree.framework.mail.SendMailException; 064 import org.deegree.security.GeneralSecurityException; 065 import org.deegree.security.UnauthorizedException; 066 import org.deegree.security.drm.ManagementException; 067 import org.deegree.security.drm.SecurityAccess; 068 import org.deegree.security.drm.SecurityAccessManager; 069 import org.deegree.security.drm.SecurityHelper; 070 import org.deegree.security.drm.SecurityTransaction; 071 import org.deegree.security.drm.UnknownException; 072 import org.deegree.security.drm.model.Group; 073 import org.deegree.security.drm.model.User; 074 075 /** 076 * This class provides the functionality to synchronize the <code>User</code> and 077 * <code>Group</code> instances stored in a <code>SecurityManager</code> with an 078 * ActiveDirectory-Server. 079 * <p> 080 * Synchronization involves four steps: 081 * <ul> 082 * <li>synchronization of groups 083 * <li>synchronization of users 084 * <li>updating of the special group "SEC_ALL" (contains all users) 085 * <li>testing of subadmin-role validity (only one role per user max) 086 * </ul> 087 * Changes are committed after all steps succeeded. If an error occurs, changes in the 088 * <code>SecurityManager</code> are undone. 089 * <p> 090 * 091 * 092 * @version $Revision: 18195 $ 093 * @author <a href="mailto:schneider@lat-lon.de">Markus Schneider</a> 094 * @author <a href="mailto:poth@lat-lon.de">Andreas Poth</a> 095 * @author last edited by: $Author: mschneider $ 096 * 097 * @version $Revision: 18195 $, $Date: 2009-06-18 17:55:39 +0200 (Do, 18 Jun 2009) $ 098 */ 099 public class ActiveDirectoryImporter { 100 101 private SecurityAccessManager manager; 102 103 private SecurityAccess access; 104 105 private SecurityTransaction trans; 106 107 private User admin; 108 109 private Hashtable<String, String> env; 110 111 private LdapContext ctx; 112 113 private Properties config; 114 115 // mail configuration 116 private static String mailSender; 117 118 private static String mailRcpt; 119 120 private static String mailHost; 121 122 private static boolean mailLog; 123 124 // number of results to fetch in one batch 125 private int pageSize = 500; 126 127 private StringBuffer logBuffer = new StringBuffer( 1000 ); 128 129 /** 130 * Constructs a new <code>ADExporter</code> -instance. 131 * <p> 132 * 133 * @param config 134 * @throws NamingException 135 * @throws GeneralSecurityException 136 */ 137 ActiveDirectoryImporter( Properties config ) throws NamingException, GeneralSecurityException { 138 139 this.config = config; 140 141 // retrieve mail configuration first 142 mailSender = getPropertySafe( "mailSender" ); 143 mailRcpt = getPropertySafe( "mailRcpt" ); 144 mailHost = getPropertySafe( "mailHost" ); 145 mailLog = getPropertySafe( "mailLog" ).equals( "true" ) || getPropertySafe( "mailLog" ).equals( "yes" ) ? true 146 : false; 147 148 // get a SecurityManager (with an SQLRegistry) 149 Properties registryProperties = new Properties(); 150 registryProperties.put( "driver", getPropertySafe( "sqlDriver" ) ); 151 registryProperties.put( "url", getPropertySafe( "sqlLogon" ) ); 152 registryProperties.put( "user", getPropertySafe( "sqlUser" ) ); 153 registryProperties.put( "password", getPropertySafe( "sqlPass" ) ); 154 155 // default timeout: 20 min 156 long timeout = 1200000; 157 try { 158 timeout = Long.parseLong( getPropertySafe( "timeout" ) ); 159 } catch ( NumberFormatException e ) { 160 logBuffer.append( "Specified property value for timeout invalid. " + "Defaulting to 1200 (secs)." ); 161 } 162 163 if ( !SecurityAccessManager.isInitialized() ) { 164 SecurityAccessManager.initialize( "org.deegree.security.drm.SQLRegistry", registryProperties, timeout ); 165 } 166 167 manager = SecurityAccessManager.getInstance(); 168 admin = manager.getUserByName( getPropertySafe( "u3rAdminName" ) ); 169 admin.authenticate( getPropertySafe( "u3rAdminPassword" ) ); 170 171 // prepare LDAP connection 172 String jndiURL = "ldap://" + getPropertySafe( "ldapHost" ) + ":389/"; 173 String initialContextFactory = "com.sun.jndi.ldap.LdapCtxFactory"; 174 String authenticationMode = "simple"; 175 String contextReferral = "ignore"; 176 env = new Hashtable<String, String>(); 177 env.put( Context.INITIAL_CONTEXT_FACTORY, initialContextFactory ); 178 env.put( Context.PROVIDER_URL, jndiURL ); 179 env.put( Context.SECURITY_AUTHENTICATION, authenticationMode ); 180 env.put( Context.SECURITY_PRINCIPAL, getPropertySafe( "ldapUser" ) ); 181 env.put( Context.SECURITY_CREDENTIALS, getPropertySafe( "ldapPass" ) ); 182 env.put( Context.REFERRAL, contextReferral ); 183 184 access = manager.acquireAccess( admin ); 185 trans = manager.acquireTransaction( admin ); 186 ctx = new InitialLdapContext( env, null ); 187 188 } 189 190 /** 191 * Returns a configuration property. If it is not defined, an exception is thrown. 192 * <p> 193 * 194 * @param name 195 * @return a configuration property. If it is not defined, an exception is thrown. 196 */ 197 private String getPropertySafe( String name ) { 198 199 String value = config.getProperty( name ); 200 if ( value == null ) { 201 throw new RuntimeException( "Configuration does not define needed property '" + name + "'." ); 202 } 203 return value; 204 } 205 206 /** 207 * Synchronizes the AD's group objects with the SecurityManager's group objects. 208 * <p> 209 * 210 * @return the mapping of the String to the Groups 211 * @throws NamingException 212 * @throws IOException 213 * @throws GeneralSecurityException 214 * @throws UnauthorizedException 215 */ 216 HashMap<String, Group> synchronizeGroups() 217 throws NamingException, IOException, UnauthorizedException, GeneralSecurityException { 218 // keys are names (Strings), values are Group-objects 219 HashMap<String, Group> groupMap = new HashMap<String, Group>( 20 ); 220 // keys are distinguishedNames (Strings), values are Group-objects 221 HashMap<String, Group> groupMap2 = new HashMap<String, Group>( 20 ); 222 // keys are names (Strings), values are NamingEnumeration-objects 223 HashMap<String, NamingEnumeration<?>> memberOfMap = new HashMap<String, NamingEnumeration<?>>( 20 ); 224 225 byte[] cookie = null; 226 227 // specify the ids of the attributes to return 228 String[] attrIDs = { "distinguishedName", getPropertySafe( "groupName" ), getPropertySafe( "groupTitle" ), 229 getPropertySafe( "groupMemberOf" ) }; 230 231 // set SearchControls 232 SearchControls ctls = new SearchControls(); 233 ctls.setReturningAttributes( attrIDs ); 234 ctls.setSearchScope( SearchControls.SUBTREE_SCOPE ); 235 236 // specify the search filter to match 237 // ask for objects that have the attribute "objectCategory" = 238 // "CN=Group*" 239 String filter = getPropertySafe( "groupFilter" ); 240 String context = getPropertySafe( "groupContext" ); 241 242 // create initial PagedResultsControl 243 ctx.setRequestControls( new Control[] { new PagedResultsControl( pageSize, false ) } ); 244 245 // phase 1: make sure that all groups from AD are present in the 246 // SecurityManager 247 // (register them to the SecurityManager if necessary) 248 do { 249 // perform the search 250 NamingEnumeration<?> answer = ctx.search( context, filter, ctls ); 251 252 // process results of the last batch 253 while ( answer.hasMoreElements() ) { 254 SearchResult result = (SearchResult) answer.nextElement(); 255 Attributes atts = result.getAttributes(); 256 String distinguishedName = (String) atts.get( "distinguishedName" ).get(); 257 String name = (String) atts.get( getPropertySafe( "groupName" ) ).get(); 258 String title = (String) atts.get( getPropertySafe( "groupTitle" ) ).get(); 259 260 // check if group is already registered 261 Group group = null; 262 try { 263 group = access.getGroupByName( name ); 264 } catch ( UnknownException e ) { 265 // no -> register group 266 logBuffer.append( "Registering group: " + name + "\n" ); 267 group = trans.registerGroup( name, title ); 268 } 269 groupMap.put( name, group ); 270 groupMap2.put( distinguishedName, group ); 271 if ( atts.get( getPropertySafe( "groupMemberOf" ) ) != null ) { 272 memberOfMap.put( name, atts.get( getPropertySafe( "groupMemberOf" ) ).getAll() ); 273 } 274 } 275 276 // examine the paged results control response 277 Control[] controls = ctx.getResponseControls(); 278 if ( controls != null ) { 279 for ( int i = 0; i < controls.length; i++ ) { 280 if ( controls[i] instanceof PagedResultsResponseControl ) { 281 PagedResultsResponseControl prrc = (PagedResultsResponseControl) controls[i]; 282 // total = prrc.getResultSize(); 283 cookie = prrc.getCookie(); 284 } 285 } 286 } 287 288 if ( cookie != null ) { 289 // re-activate paged results 290 ctx.setRequestControls( new Control[] { new PagedResultsControl( pageSize, cookie, Control.CRITICAL ) } ); 291 } 292 } while ( cookie != null ); 293 294 // phase 2: make sure that all groups from the SecurityManager are known 295 // to the AD 296 // (deregister them from the SecurityManager if necessary) 297 Group[] sMGroups = access.getAllGroups(); 298 for ( int i = 0; i < sMGroups.length; i++ ) { 299 if ( groupMap.get( sMGroups[i].getName() ) == null && sMGroups[i].getID() != Group.ID_SEC_ADMIN 300 && !( sMGroups[i].getName().equals( "SEC_ALL" ) ) ) { 301 logBuffer.append( "Deregistering group: " + sMGroups[i].getName() + "\n" ); 302 trans.deregisterGroup( sMGroups[i] ); 303 } 304 } 305 306 // phase 3: set the membership-relations between the groups 307 Iterator<String> it = groupMap.keySet().iterator(); 308 while ( it.hasNext() ) { 309 String name = it.next(); 310 Group group = groupMap.get( name ); 311 NamingEnumeration<?> memberOf = memberOfMap.get( name ); 312 ArrayList<Group> memberOfList = new ArrayList<Group>( 5 ); 313 314 if ( memberOf != null ) { 315 while ( memberOf.hasMoreElements() ) { 316 String memberGroupName = (String) memberOf.nextElement(); 317 Group memberGroup = groupMap2.get( memberGroupName ); 318 if ( memberGroup != null ) { 319 memberOfList.add( memberGroup ); 320 } else { 321 logBuffer.append( "Group " + name + " is member of unknown group " + memberGroupName 322 + ". Membership ignored.\n" ); 323 } 324 } 325 } 326 Group[] newGroups = memberOfList.toArray( new Group[memberOfList.size()] ); 327 trans.setGroupsForGroup( group, newGroups ); 328 } 329 return groupMap2; 330 } 331 332 /** 333 * Synchronizes the AD's user objects with the SecurityManager's user objects. 334 * <p> 335 * 336 * @param groups 337 * 338 * @throws NamingException 339 * @throws IOException 340 * @throws GeneralSecurityException 341 * @throws UnauthorizedException 342 * 343 */ 344 void synchronizeUsers( HashMap<String, Group> groups ) 345 throws NamingException, IOException, UnauthorizedException, GeneralSecurityException { 346 // keys are names (Strings), values are User-objects 347 HashMap<String, User> userMap = new HashMap<String, User>( 20 ); 348 // keys are names (Strings), values are NamingEnumeration-objects 349 HashMap<String, NamingEnumeration<?>> memberOfMap = new HashMap<String, NamingEnumeration<?>>( 20 ); 350 351 byte[] cookie = null; 352 353 // specify the ids of the attributes to return 354 String[] attrIDs = { getPropertySafe( "userName" ), getPropertySafe( "userTitle" ), 355 getPropertySafe( "userFirstName" ), getPropertySafe( "userLastName" ), 356 getPropertySafe( "userMail" ), getPropertySafe( "userMemberOf" ) }; 357 358 SearchControls ctls = new SearchControls(); 359 ctls.setReturningAttributes( attrIDs ); 360 ctls.setSearchScope( SearchControls.SUBTREE_SCOPE ); 361 362 // specify the search filter to match 363 String filter = getPropertySafe( "userFilter" ); 364 String context = getPropertySafe( "userContext" ); 365 366 // create initial PagedResultsControl 367 ctx.setRequestControls( new Control[] { new PagedResultsControl( pageSize, false ) } ); 368 369 // phase 1: make sure that all users from AD are present in the 370 // SecurityManager 371 // (register them to the SecurityManager if necessary) 372 do { 373 // perform the search 374 NamingEnumeration<?> answer = ctx.search( context, filter, ctls ); 375 376 // process results of the last batch 377 while ( answer.hasMoreElements() ) { 378 SearchResult result = (SearchResult) answer.nextElement(); 379 380 Attributes atts = result.getAttributes(); 381 Attribute nameAtt = atts.get( getPropertySafe( "userName" ) ); 382 // Attribute titleAtt = atts.get( getPropertySafe( "userTitle" ) ); 383 Attribute firstNameAtt = atts.get( getPropertySafe( "userFirstName" ) ); 384 Attribute lastNameAtt = atts.get( getPropertySafe( "userLastName" ) ); 385 Attribute mailAtt = atts.get( getPropertySafe( "userMail" ) ); 386 Attribute memberOfAtt = atts.get( getPropertySafe( "userMemberOf" ) ); 387 388 String name = (String) nameAtt.get(); 389 // String title = titleAtt != null ? (String) titleAtt.get() : ""; 390 String firstName = firstNameAtt != null ? (String) firstNameAtt.get() : "" + ""; 391 String lastName = lastNameAtt != null ? (String) lastNameAtt.get() : "" + ""; 392 String mail = mailAtt != null ? (String) mailAtt.get() : ""; 393 394 // check if user is already registered 395 User user = null; 396 try { 397 user = access.getUserByName( name ); 398 } catch ( UnknownException e ) { 399 // no -> register user 400 logBuffer.append( "Registering user: " + name + "\n" ); 401 user = trans.registerUser( name, null, lastName, firstName, mail ); 402 } 403 userMap.put( name, user ); 404 405 if ( memberOfAtt != null ) { 406 memberOfMap.put( name, memberOfAtt.getAll() ); 407 } 408 } 409 410 // examine the paged results control response 411 Control[] controls = ctx.getResponseControls(); 412 if ( controls != null ) { 413 for ( int i = 0; i < controls.length; i++ ) { 414 if ( controls[i] instanceof PagedResultsResponseControl ) { 415 PagedResultsResponseControl prrc = (PagedResultsResponseControl) controls[i]; 416 // total = prrc.getResultSize(); 417 cookie = prrc.getCookie(); 418 } 419 } 420 } 421 422 if ( cookie != null ) { 423 // re-activate paged results 424 ctx.setRequestControls( new Control[] { new PagedResultsControl( pageSize, cookie, Control.CRITICAL ) } ); 425 } 426 } while ( cookie != null ); 427 428 // phase 2: make sure that all users from the SecurityManager are known 429 // to the AD 430 // (deregister them from the SecurityManager if necessary) 431 User[] sMUsers = access.getAllUsers(); 432 for ( int i = 0; i < sMUsers.length; i++ ) { 433 if ( userMap.get( sMUsers[i].getName() ) == null && sMUsers[i].getID() != User.ID_SEC_ADMIN ) { 434 logBuffer.append( "Deregistering user: " + sMUsers[i].getName() + "\n" ); 435 trans.deregisterUser( sMUsers[i] ); 436 } 437 } 438 439 // phase 3: set the membership-relations between the groups and the 440 // users 441 Iterator<String> it = userMap.keySet().iterator(); 442 while ( it.hasNext() ) { 443 String name = it.next(); 444 User user = userMap.get( name ); 445 NamingEnumeration<?> memberOf = memberOfMap.get( name ); 446 ArrayList<Group> memberOfList = new ArrayList<Group>( 5 ); 447 if ( memberOf != null ) { 448 while ( memberOf.hasMoreElements() ) { 449 String memberGroupName = (String) memberOf.nextElement(); 450 Group memberGroup = groups.get( memberGroupName ); 451 if ( memberGroup != null ) { 452 memberOfList.add( memberGroup ); 453 } else { 454 logBuffer.append( "User " + name + " is member of unknown group " + memberGroupName 455 + ". Membership ignored.\n" ); 456 } 457 } 458 } 459 Group[] newGroups = memberOfList.toArray( new Group[memberOfList.size()] ); 460 trans.setGroupsForUser( user, newGroups ); 461 } 462 463 } 464 465 /** 466 * Updates the special group "SEC_ALL" (contains all users). 467 * <p> 468 * 469 * @throws GeneralSecurityException 470 * 471 */ 472 void updateSecAll() 473 throws GeneralSecurityException { 474 Group secAll = null; 475 476 // phase1: make sure that group "SEC_ALL" exists 477 // (register it if necessary) 478 try { 479 secAll = access.getGroupByName( "SEC_ALL" ); 480 } catch ( UnknownException e ) { 481 secAll = trans.registerGroup( "SEC_ALL", "SEC_ALL" ); 482 } 483 484 // phase2: set all users to be members of this group 485 User[] allUsers = access.getAllUsers(); 486 trans.setUsersInGroup( secAll, allUsers ); 487 488 } 489 490 /** 491 * Checks subadmin-role validity (each user one role max). 492 * <p> 493 * 494 * @throws ManagementException 495 * @throws GeneralSecurityException 496 */ 497 void checkSubadminRoleValidity() 498 throws ManagementException, GeneralSecurityException { 499 SecurityHelper.checkSubadminRoleValidity( access ); 500 } 501 502 /** 503 * Aborts the synchronization process and undoes all changes. 504 */ 505 public void abortChanges() { 506 if ( manager != null && trans != null ) { 507 try { 508 manager.abortTransaction( trans ); 509 } catch ( GeneralSecurityException e ) { 510 e.printStackTrace(); 511 } 512 } 513 if ( ctx != null ) { 514 try { 515 ctx.close(); 516 } catch ( NamingException e ) { 517 e.printStackTrace(); 518 } 519 } 520 } 521 522 /** 523 * Ends the synchronization process and commits all changes. 524 */ 525 public void commitChanges() { 526 if ( manager != null && trans != null ) { 527 try { 528 manager.commitTransaction( trans ); 529 } catch ( GeneralSecurityException e ) { 530 e.printStackTrace(); 531 } 532 } 533 if ( ctx != null ) { 534 try { 535 ctx.close(); 536 } catch ( NamingException e ) { 537 e.printStackTrace(); 538 } 539 } 540 } 541 542 /** 543 * Sends an eMail to inform the admin that something went wrong. 544 * <p> 545 * NOTE: This is static, because it must be usable even when the construction of the ADExporter 546 * failed. 547 * <p> 548 * 549 * @param e 550 */ 551 public static void sendError( Exception e ) { 552 553 try { 554 String mailMessage = "Beim Synchronisieren des ActiveDirectory mit der HUIS-" 555 + "Sicherheitsdatenbank ist ein Fehler aufgetreten.\n" 556 + "Die Synchronisierung wurde NICHT durchgeführt, der letzte " 557 + "Stand wurde wiederhergestellt.\n"; 558 StringWriter sw = new StringWriter(); 559 PrintWriter writer = new PrintWriter( sw ); 560 e.printStackTrace( writer ); 561 mailMessage += "\n\nDie Java-Fehlermeldung lautet:\n" + sw.getBuffer(); 562 563 mailMessage += "\n\nMit freundlichem Gruss,\nIhr ADExporter"; 564 MailHelper.createAndSendMail( 565 new EMailMessage( mailSender, mailRcpt, "Fehler im ADExporter", mailMessage ), 566 mailHost ); 567 } catch ( SendMailException ex ) { 568 ex.printStackTrace(); 569 } 570 571 } 572 573 /** 574 * Sends an eMail with a log of the transaction. 575 * <p> 576 */ 577 public void sendLog() { 578 579 try { 580 String mailMessage = "Die Synchronisierung der HUIS-Sicherheitsdatenbank mit " 581 + "dem ActiveDirectory wurde erfolgreich durchgeführt:\n\n"; 582 if ( logBuffer.length() == 0 ) { 583 mailMessage += "Keine Änderungen."; 584 } else { 585 mailMessage += logBuffer.toString(); 586 } 587 mailMessage += "\n\nMit freundlichem Gruss,\nIhr ADExporter"; 588 EMailMessage emm = new EMailMessage( mailSender, mailRcpt, "ActiveDirectory Sychronisierung durchgeführt", 589 mailMessage ); 590 MailHelper.createAndSendMail( emm, mailHost ); 591 } catch ( SendMailException ex ) { 592 ex.printStackTrace(); 593 } 594 595 } 596 597 /** 598 * @param args 599 * @throws Exception 600 */ 601 public static void main( String[] args ) 602 throws Exception { 603 604 if ( args.length != 1 ) { 605 System.out.println( "USAGE: ADExporter configfile" ); 606 System.exit( 0 ); 607 } 608 609 long begin = System.currentTimeMillis(); 610 System.out.println( "Beginning synchronisation..." ); 611 612 ActiveDirectoryImporter exporter = null; 613 try { 614 Properties config = new Properties(); 615 config.load( new FileInputStream( args[0] ) ); 616 exporter = new ActiveDirectoryImporter( config ); 617 618 HashMap<String, Group> groups = exporter.synchronizeGroups(); 619 exporter.synchronizeUsers( groups ); 620 exporter.updateSecAll(); 621 exporter.checkSubadminRoleValidity(); 622 exporter.commitChanges(); 623 } catch ( Exception e ) { 624 if ( exporter != null ) { 625 exporter.abortChanges(); 626 } 627 sendError( e ); 628 System.err.println( "Synchronisation has been aborted. Error message: " ); 629 e.printStackTrace(); 630 System.exit( 0 ); 631 } 632 633 if ( mailLog ) { 634 exporter.sendLog(); 635 } 636 637 System.out.println( "Synchronisation took " + ( System.currentTimeMillis() - begin ) + " milliseconds." ); 638 639 System.exit( 0 ); 640 } 641 }