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