Quellcode durchsuchen

Hardened for web-facing user interface.

* Deleted "Register User" API
* Added reset password API
* Fixed issues with server vulnerabilities.
Thomas Flucke vor 1 Jahr
Ursprung
Commit
db70d4928e

+ 37 - 15
server/app/com/weEat/controllers/UserController.scala

@@ -6,7 +6,7 @@ import play.api.mvc._
 import play.api.libs.json._
 import play.api.libs.json._
 import com.weEat.services.OAuth2Service
 import com.weEat.services.OAuth2Service
 import com.weEat.models.{Authorization,User}
 import com.weEat.models.{Authorization,User}
-import com.weEat.shared.models.UserRegistration
+import com.weEat.shared.models.{UserRegistration,PasswordChange}
 import scalaoauth2.provider._
 import scalaoauth2.provider._
 import scala.concurrent.Future
 import scala.concurrent.Future
 import scala.util.{Success,Failure}
 import scala.util.{Success,Failure}
@@ -103,22 +103,43 @@ class UserController @Inject()(
       case Some(reason) => Future.successful(BadRequest(reason))
       case Some(reason) => Future.successful(BadRequest(reason))
       case None => {
       case None => {
         val user = User(body)
         val user = User(body)
-        withCollection(User) { collection =>
-          {
-          collection.insertOne(user).head().map(_ =>
-            {
-            issueAccessToken(oauth)(Request[Map[String, Seq[String]]](
-              request.withHeaders(Headers(
-                "Authorization" -> encodeBasicAuth(user.email, body.password)
-              )),
-              Map("grant_type" -> Seq(OAuthGrantType.PASSWORD))
-            ), ec)}
-          ).flatten}
+        withCollection(User) { (collection) =>
+          collection.insertOne(user).head().map((_) =>
+            newTokenForUser(user, body.password)
+          ).flatten
         }.flatten
         }.flatten
       }
       }
     }
     }
   }
   }
 
 
+  // TODO: Unit test API
+  def changePassword() = AuthorizedAction[Authorization](oauth).async(parse.json)
+  { implicit request: AuthInfoRequest[JsValue, Authorization] =>
+    val body = request.body.as[PasswordChange]
+    oauth.validateUsernamePassword(request.authInfo.user.email, body.oldPassword) flatMap {
+      case Some(user) => withCollection(User) { (users) =>
+        users.replaceOne(
+          equal("_id", request.authInfo.user.userId),
+          user.changePassword(body.newPassword)
+        ).head().transform {
+          case Success(_) => Success(
+            newTokenForUser(user, body.newPassword)
+          )
+          case Failure(e) => Failure(e)
+        } flatten
+      } flatten
+      case None => Future.failed(new UnauthorizedClient("password does not match"))
+    }
+  }
+
+  def newTokenForUser(user: User, password: String)(implicit request: Request[_]) =
+    issueAccessToken(oauth)(Request[Map[String, Seq[String]]](
+      request.withHeaders(Headers(
+        "Authorization" -> encodeBasicAuth(user.email, password)
+      )),
+      Map("grant_type" -> Seq(OAuthGrantType.PASSWORD))
+    ), ec)
+
   def getName() = AuthorizedAction[Authorization](oauth).async
   def getName() = AuthorizedAction[Authorization](oauth).async
   { implicit request: AuthInfoRequest[AnyContent, Authorization] =>
   { implicit request: AuthInfoRequest[AnyContent, Authorization] =>
     getUser(request.authInfo.user.userId).map({
     getUser(request.authInfo.user.userId).map({
@@ -131,8 +152,9 @@ class UserController @Inject()(
   // TODO: Unit test API
   // TODO: Unit test API
   def getUsers() = Action.async
   def getUsers() = Action.async
   { implicit request: Request[AnyContent] =>
   { implicit request: Request[AnyContent] =>
-    withCollection(User) { collection =>
-      collection.find().map(res => res.email)
+    withCollection(User) { (users) =>
+      // TODO: Email is a privacy issue.  Change to a generic username
+      users.find().map((res) => res.email)
         .toFuture()
         .toFuture()
         .transform({
         .transform({
           case Success(x) => Success(Ok(Json.toJson(x)))
           case Success(x) => Success(Ok(Json.toJson(x)))
@@ -141,7 +163,7 @@ class UserController @Inject()(
     }.flatten
     }.flatten
   }
   }
 
 
-  def getUser(id: ObjectId): Future[Option[User]] = withCollection(User) { users =>
+  def getUser(id: ObjectId): Future[Option[User]] = withCollection(User) { (users) =>
     users.find(equal("_id", id))
     users.find(equal("_id", id))
       .first()
       .first()
       .toFutureOption()
       .toFutureOption()

+ 23 - 16
server/app/com/weEat/controllers/ViewController.scala

@@ -21,17 +21,23 @@ class ViewController @Inject()(
 
 
   implicit val ec = scala.concurrent.ExecutionContext.global
   implicit val ec = scala.concurrent.ExecutionContext.global
 
 
-  private var initalized = false
+  private var initialized = false
 
 
-  def initalizer() = Action.async { implicit request: Request[AnyContent] =>
-    db.withCollection(User) { users =>
+  private def _is_initialized: Future[Boolean] = {
+    if (initialized) Future.successful(true)
+    else db.withCollection(User) { (users) =>
       import org.mongodb.scala.model.Filters._
       import org.mongodb.scala.model.Filters._
       users.find(equal("isAdmin", true)).head()
       users.find(equal("isAdmin", true)).head()
-    }.flatten.map {
-      case null => Ok(views.html.initalizer())
-      case _ =>
-        initalized = true;
-        MovedPermanently(routes.ViewController.loader("").url)
+    }.flatten.map({ (user) =>
+      initialized = user != null
+      initialized
+    })
+  }
+
+  def initalizer() = Action.async { implicit request: Request[AnyContent] =>
+    _is_initialized map {
+      case true  => MovedPermanently(routes.ViewController.loader("").url)
+      case false => Ok(views.html.initalizer())
     }
     }
   }
   }
 
 
@@ -39,7 +45,8 @@ class ViewController @Inject()(
   // I don't like it, but it is unique among every other bit of functionality.
   // I don't like it, but it is unique among every other bit of functionality.
   def initalize() = Action.async(parse.formUrlEncoded) {
   def initalize() = Action.async(parse.formUrlEncoded) {
     implicit request: Request[Map[String, Seq[String]]] =>
     implicit request: Request[Map[String, Seq[String]]] =>
-    db.withCollection(User) { users =>
+    // First verify that we haven't already been initialized
+    db.withCollection(User) { (users) =>
       import org.mongodb.scala.model.Filters._
       import org.mongodb.scala.model.Filters._
       import com.github.t3hnar.bcrypt.BCryptStrOps
       import com.github.t3hnar.bcrypt.BCryptStrOps
       users.find(equal("isAdmin", true)).head().flatMap {
       users.find(equal("isAdmin", true)).head().flatMap {
@@ -53,16 +60,16 @@ class ViewController @Inject()(
         )).head()
         )).head()
         case existingAdmin => Future.successful(existingAdmin)
         case existingAdmin => Future.successful(existingAdmin)
       }
       }
-    }.flatten.map({ _ =>
-      initalized = true;
+    }.flatten.map({ (_) =>
+      initialized = true;
       MovedPermanently(routes.ViewController.loader("").url)
       MovedPermanently(routes.ViewController.loader("").url)
     })
     })
   }
   }
 
 
-  def loader(s: String) = Action { implicit request: Request[AnyContent] =>
-    if (initalized)
-      Ok(views.html.viewLoader())
-    else
-      TemporaryRedirect(routes.ViewController.initalizer().url)
+  def loader(s: String) = Action.async { implicit request: Request[AnyContent] =>
+    _is_initialized map {
+      case true  => Ok(views.html.viewLoader())
+      case false => TemporaryRedirect(routes.ViewController.initalizer().url)
+    }
   }
   }
 }
 }

+ 5 - 0
server/app/com/weEat/models/User.scala

@@ -27,6 +27,11 @@ case class User (
     lname,
     lname,
     email
     email
   )
   )
+
+  import com.github.t3hnar.bcrypt.BCryptStrOps
+  def changePassword(pwd: String) = copy(
+    password = pwd.boundedBcrypt
+  )
 }
 }
 
 
 object User extends Collectable[User] {
 object User extends Collectable[User] {

+ 63 - 56
server/app/com/weEat/services/OAuth2Service.scala

@@ -14,7 +14,6 @@ import org.mongodb.scala.bson.DefaultBsonTransformers
 import org.mongodb.scala.bson.conversions.Bson
 import org.mongodb.scala.bson.conversions.Bson
 import scala.language.implicitConversions
 import scala.language.implicitConversions
 
 
-// TOOD: Migrate to SecureSocial OAuth lib
 @Singleton
 @Singleton
 class OAuth2Service @Inject()(db: MongoDBService, config: Configuration)
 class OAuth2Service @Inject()(db: MongoDBService, config: Configuration)
     extends AuthorizationHandler[User]
     extends AuthorizationHandler[User]
@@ -26,10 +25,9 @@ class OAuth2Service @Inject()(db: MongoDBService, config: Configuration)
   import db.withCollection
   import db.withCollection
 
 
   override def createAccessToken(auth: AuthInfo[User]) = {
   override def createAccessToken(auth: AuthInfo[User]) = {
-    println("Create token")
     val newAuth = Authorization(auth.user)
     val newAuth = Authorization(auth.user)
-    withCollection(Authorization) {auths =>
-      auths.insertOne(newAuth).head().map(_ => newAuth.toToken())
+    withCollection(Authorization) { (auths) =>
+      auths.insertOne(newAuth).head().map((_) => newAuth.toToken())
     }.flatten
     }.flatten
   }
   }
 
 
@@ -39,39 +37,43 @@ class OAuth2Service @Inject()(db: MongoDBService, config: Configuration)
     cred: Option[ClientCredential],
     cred: Option[ClientCredential],
     requ: AuthorizationRequest
     requ: AuthorizationRequest
   ): Future[Boolean] = {
   ): Future[Boolean] = {
-    val cc = cred.getOrElse(throw new UnauthorizedClient("username required"))
-    requ.grantType match {
-      case OAuthGrantType.PASSWORD => validateUsernamePassword(
-        cc.clientId,
-        cc.clientSecret.getOrElse(throw new AccessDenied("password required"))
-      )
-      case OAuthGrantType.REFRESH_TOKEN => validateUsernameRefresh(
-        cc.clientId,
-        Authorization.decodeToken(RefreshTokenRequest(requ).refreshToken)
-      )
-      case OAuthGrantType.AUTHORIZATION_CODE => throw new UnsupportedGrantType()
-      case OAuthGrantType.CLIENT_CREDENTIALS => throw new UnsupportedGrantType()
-      case OAuthGrantType.IMPLICIT => throw new UnsupportedGrantType()
+    cred.fold[Future[Boolean]](Future.failed(new UnauthorizedClient("username required"))) { (cc) =>
+      requ.grantType match {
+        case OAuthGrantType.PASSWORD =>
+          cc.clientSecret.fold[Future[Boolean]](Future.failed(new UnauthorizedClient("password required"))) { (sec) =>
+            validateUsernamePassword(cc.clientId, sec).map(_.isDefined)
+          }
+        case OAuthGrantType.REFRESH_TOKEN => validateUsernameRefresh(
+          cc.clientId,
+          Authorization.decodeToken(RefreshTokenRequest(requ).refreshToken)
+        )
+        case OAuthGrantType.AUTHORIZATION_CODE => Future.failed(new UnsupportedGrantType())
+        case OAuthGrantType.CLIENT_CREDENTIALS => Future.failed(new UnsupportedGrantType())
+        case OAuthGrantType.IMPLICIT => Future.failed(new UnsupportedGrantType())
+      }
     }
     }
   }
   }
 
 
-  private def withDevModeCond(bson: Bson) =
-    if (devMode) bson else and(bson, not(equal("devOnly", true)))
-
-  private def validateUsernamePassword(
+  def validateUsernamePassword(
     username: String,
     username: String,
     pass: String
     pass: String
-  ): Future[Boolean] = withCollection(User) {users =>
+  ): Future[Option[User]] = withCollection(User) { (users) =>
     users.find(withDevModeCond(equal("email", username)))
     users.find(withDevModeCond(equal("email", username)))
       .first()
       .first()
       .toFutureOption()
       .toFutureOption()
-      .map(res => res.map { x => pass.isBcryptedSafeBounded(x.password)}
-        .getOrElse(Success(false))
-      ).transform ({ _.flatten })
+      // TODO: Replace bcrypt with something that doesn't use String objects.
+      .filter {
+        case Some(user) => pass.isBcryptedSafeBounded(user.password) == Success(true)
+        case None => false
+      }
+      
   }.flatten
   }.flatten
 
 
+  private def withDevModeCond(bson: Bson) =
+    if (devMode) bson else and(bson, not(equal("devOnly", true)))
+
   private def validateUsernameRefresh(username: String, refresh: Array[Byte]) = 
   private def validateUsernameRefresh(username: String, refresh: Array[Byte]) = 
-  withCollection(Authorization) {auths =>
+  withCollection(Authorization) { (auths) =>
     auths.find(and(
     auths.find(and(
       equal("email", username),
       equal("email", username),
       equal("refreshToken", refresh)
       equal("refreshToken", refresh)
@@ -81,11 +83,11 @@ class OAuth2Service @Inject()(db: MongoDBService, config: Configuration)
   override def findUser(
   override def findUser(
     cred: Option[ClientCredential],
     cred: Option[ClientCredential],
     requ: AuthorizationRequest
     requ: AuthorizationRequest
-  ): Future[Option[User]] = withCollection(User) {users =>
-    users.find(withDevModeCond(equal("email", cred.getOrElse(
-      throw new UnsupportedGrantType("client_id required")
-    ).clientId))).first().toFutureOption()
-  }.flatten.map({x => {println(s"findUser: $x"); x}})
+  ): Future[Option[User]] = withCollection(User) { (users) =>
+    cred.fold[Future[Option[User]]](Future.failed(new UnsupportedGrantType("client_id required"))) { (cc) =>
+      users.find(withDevModeCond(equal("email", cc.clientId))).first().toFutureOption()
+    }
+  }.flatten
   
   
   /* 2020-07-25: Never re-issue the same authorization token. Always generate a
   /* 2020-07-25: Never re-issue the same authorization token. Always generate a
    * new one.
    * new one.
@@ -94,62 +96,67 @@ class OAuth2Service @Inject()(db: MongoDBService, config: Configuration)
     Future.successful(None)
     Future.successful(None)
   }
   }
 
 
-  private def freshAccessToken(token: Array[Byte]) = and(
-    gt("created", Instant.now() - Authorization.accessFreshTime),
-    equal("accessToken", token)
-  )
-
   override def findAccessToken(token: String): Future[Option[AccessToken]] = 
   override def findAccessToken(token: String): Future[Option[AccessToken]] = 
-    withCollection(Authorization) {auths =>
+    withCollection(Authorization) { (auths) =>
       auths.find(
       auths.find(
         freshAccessToken(Authorization.decodeToken(token))
         freshAccessToken(Authorization.decodeToken(token))
       ).first().toFutureOption()
       ).first().toFutureOption()
         .map(_.map(_.toToken()))
         .map(_.map(_.toToken()))
-    }.flatten.map({x => {println(s"findAccessToken: $x"); x}})    
+    }.flatten    
 
 
   override def findAuthInfoByAccessToken(token: AccessToken) =
   override def findAuthInfoByAccessToken(token: AccessToken) =
-    withCollection(Authorization) {collection =>
-      collection.find(freshAccessToken(Authorization.decodeToken(token.token)))
+    withCollection(Authorization) { (auths) =>
+      auths.find(freshAccessToken(Authorization.decodeToken(token.token)))
         .first()
         .first()
         .toFutureOption()
         .toFutureOption()
         .map(_.map(auth => AuthInfo(auth, Some(auth.email), None, None)))
         .map(_.map(auth => AuthInfo(auth, Some(auth.email), None, None)))
     }.flatten
     }.flatten
 
 
+  private def freshAccessToken(token: Array[Byte]) = and(
+    gt("created", Instant.now() - Authorization.accessFreshTime),
+    equal("accessToken", token)
+  )
+
   implicit def optFut2FutOpt[A](
   implicit def optFut2FutOpt[A](
     x: Option[Future[A]]
     x: Option[Future[A]]
-  )(implicit ec: ExecutionContext): Future[Option[A]] = x match {
-    case Some(f) => f.map(Some(_))
-    case None    => Future.successful(None)
+  )(implicit ec: ExecutionContext): Future[Option[A]] = x.fold[Future[Option[A]]](Future.successful(None)) {
+    _.map(Some(_))
   }
   }
 
 
   override def findAuthInfoByRefreshToken(token: String) =
   override def findAuthInfoByRefreshToken(token: String) =
-    withCollection(Authorization) {auths =>
-      auths.find(equal("refreshToken", Authorization.decodeToken(token)))
+    withCollection(Authorization) { (auths) =>
+      auths.find(freshRefreshToken(Authorization.decodeToken(token)))
         .first()
         .first()
         .toFutureOption()
         .toFutureOption()
     }.flatten
     }.flatten
-      .map(authOpt => withCollection(User) {users =>
-        optFut2FutOpt(authOpt.map({ auth => users.find(
+      .map(authOpt => withCollection(User) { (users) =>
+        optFut2FutOpt(authOpt.map({ (auth) => users.find(
           withDevModeCond(equal("_id", auth.userId))
           withDevModeCond(equal("_id", auth.userId))
         ).first()
         ).first()
           .toFuture()
           .toFuture()
-          .map({user => AuthInfo(user, Some(user.email), None, None)})
+          .map({ (user) => AuthInfo(user, Some(user.email), None, None)})
         }))
         }))
       }.flatten).flatten
       }.flatten).flatten
-    
+  
+  private def freshRefreshToken(token: Array[Byte]) = and(
+    gt("created", Instant.now() - Authorization.refreshFreshTime),
+    equal("refreshToken", token)
+  )
+
   override def refreshAccessToken(
   override def refreshAccessToken(
     auth: AuthInfo[User],
     auth: AuthInfo[User],
     refreshToken: String
     refreshToken: String
   ): Future[AccessToken] = {
   ): Future[AccessToken] = {
     val newAuth = Authorization(auth.user)
     val newAuth = Authorization(auth.user)
-    withCollection(Authorization) {auths =>
+    withCollection(Authorization) { (auths) =>
       auths.replaceOne(
       auths.replaceOne(
         and(
         and(
           equal("userId", auth.user._id),
           equal("userId", auth.user._id),
-          equal("refreshToken", Authorization.decodeToken(refreshToken))
+          equal("refreshToken", Authorization.decodeToken(refreshToken)),
+          gt("created", Instant.now() - Authorization.refreshFreshTime),
         ),
         ),
         newAuth
         newAuth
-      ).head().map(_ => newAuth.toToken())
+      ).head().map( (_) => newAuth.toToken())
     }.flatten
     }.flatten
   }
   }
 
 
@@ -162,18 +169,18 @@ class OAuth2Service @Inject()(db: MongoDBService, config: Configuration)
       Future.failed(
       Future.failed(
         new InvalidClient("Invalid client or client is not authorized")
         new InvalidClient("Invalid client or client is not authorized")
       )
       )
-    else withCollection(Authorization) {auths =>
+    else withCollection(Authorization) { (auths) =>
       auths.deleteOne(and(
       auths.deleteOne(and(
         equal("userId", auth.user.userId),
         equal("userId", auth.user.userId),
         equal("refreshToken", Authorization.decodeToken(refresh))
         equal("refreshToken", Authorization.decodeToken(refresh))
-      )).head().map { _ => {} }
+      )).head().map { (_) => {} }
     }.flatten
     }.flatten
 
 
   override def deleteAuthCode(token: String): Future[Unit] =
   override def deleteAuthCode(token: String): Future[Unit] =
-    throw new UnsupportedGrantType("Code grant authorizations are not supported.")
+    Future.failed(new UnsupportedGrantType("Code grant authorizations are not supported."))
 
 
   override def findAuthInfoByCode(
   override def findAuthInfoByCode(
     code: String
     code: String
   ): Future[Option[AuthInfo[User]]] =
   ): Future[Option[AuthInfo[User]]] =
-    throw new UnsupportedGrantType("Code grant authorizations are not supported.")
+    Future.failed(new UnsupportedGrantType("Code grant authorizations are not supported."))
 }
 }

+ 0 - 6
server/app/views/index.scala.html

@@ -1,6 +0,0 @@
-@()(implicit webJarsUtil: org.webjars.play.WebJarsUtil, request: RequestHeader)
-
-@main("Home") {
-<h1>Hello!</h1>
-<h2>Welcome to my recipe book!</h2>
-}

+ 4 - 0
server/app/views/viewLoader.scala.html

@@ -89,6 +89,10 @@
           </li>
           </li>
         </ul>
         </ul>
         <ul id="logout-btns" class="navbar-nav" style="display: none;">
         <ul id="logout-btns" class="navbar-nav" style="display: none;">
+          <!-- TODO: Move this into a dedicated "manage my account" page -->
+          <li class="nav-item">
+            <a class="nav-link" href="#" id="btn-chg-pwd">Change Password</a>
+          </li>
           <li class="nav-item">
           <li class="nav-item">
             <a class="nav-link" href="#" id="btn-logout">Logout</a>
             <a class="nav-link" href="#" id="btn-logout">Logout</a>
           </li>
           </li>

+ 7 - 1
server/conf/routes

@@ -8,10 +8,11 @@ GET   /               @controllers.Default.redirect(to = "/foodsearch?")
 GET   /initialize     com.weEat.controllers.ViewController.initalizer()
 GET   /initialize     com.weEat.controllers.ViewController.initalizer()
 POST  /initialize     com.weEat.controllers.ViewController.initalize()
 POST  /initialize     com.weEat.controllers.ViewController.initalize()
 
 
+# TODO: make this a configurable setting
 # Shared Route
 # Shared Route
 # body: com.weEat.shared.models.UserRegistration
 # body: com.weEat.shared.models.UserRegistration
 # type: com.weEat.shared.models.UserAuthorization
 # type: com.weEat.shared.models.UserAuthorization
-PUT   /v1/user/          com.weEat.controllers.UserController.registerUser()
+#PUT   /v1/user/          com.weEat.controllers.UserController.registerUser()
 
 
 # Shared Route
 # Shared Route
 # type: Seq[String]
 # type: Seq[String]
@@ -30,6 +31,11 @@ POST  /v1/authorize/     com.weEat.controllers.UserController.accessToken()
 # body: com.weEat.shared.models.RefreshRequest
 # body: com.weEat.shared.models.RefreshRequest
 DELETE /v1/authorize/   com.weEat.controllers.UserController.revokeAccessToken()
 DELETE /v1/authorize/   com.weEat.controllers.UserController.revokeAccessToken()
 
 
+# Shared Route
+# body: com.weEat.shared.models.PasswordChange
+# type: com.weEat.shared.models.UserAuthorization
+POST /v1/authorize/changePassword com.weEat.controllers.UserController.changePassword()
+
 # Shared Route
 # Shared Route
 # mime: text/plain
 # mime: text/plain
 # type: String
 # type: String

+ 13 - 2
shared/shared/src/main/scala/com/weEat/shared/OAuthManager.scala

@@ -5,6 +5,7 @@ import com.weEat.shared.models.PasswordRequest
 import com.weEat.shared.models.RefreshRequest
 import com.weEat.shared.models.RefreshRequest
 import com.weEat.shared.models.UserAuthorization
 import com.weEat.shared.models.UserAuthorization
 import com.weEat.shared.models.UserRegistration
 import com.weEat.shared.models.UserRegistration
+import com.weEat.shared.models.PasswordChange
 import com.tflucke.webroutes.Headers
 import com.tflucke.webroutes.Headers
 import scala.concurrent.ExecutionContext
 import scala.concurrent.ExecutionContext
 
 
@@ -19,8 +20,8 @@ object OAuthManager {
   def signup(reg: UserRegistration)(implicit
   def signup(reg: UserRegistration)(implicit
     headers: Headers,
     headers: Headers,
     c: ExecutionContext
     c: ExecutionContext
-  ): Future[Unit] =
-    UserController.registerUser()(reg) map(_loginComplete(reg.email))
+  ): Future[Unit] = ???
+    //UserController.registerUser()(reg) map(_loginComplete(reg.email))
 
 
   def login(headers: Headers = Headers.empty)(loginPair: (String, String))(
   def login(headers: Headers = Headers.empty)(loginPair: (String, String))(
     implicit c: ExecutionContext
     implicit c: ExecutionContext
@@ -51,6 +52,16 @@ object OAuthManager {
       new IllegalStateException("No login information.")
       new IllegalStateException("No login information.")
     )).map({ (_) => _clear() })
     )).map({ (_) => _clear() })
 
 
+  def changePassword(pwdChg: PasswordChange)(implicit headers: Headers, c: ExecutionContext = ctx): Future[Unit] =
+    username.fold[Future[Unit]](Future.failed(new IllegalStateException("No login information."))) { (uname) =>
+      authHeader.fold[Future[Unit]](Future.failed(new IllegalStateException("No login information."))) { (auth) =>
+        UserController.changePassword()(pwdChg)(
+          implicitly,
+          headers + auth
+        ).map(_loginComplete(uname))
+      }
+    }
+
   def refreshToken()(implicit
   def refreshToken()(implicit
     headers: Headers,
     headers: Headers,
     c: ExecutionContext
     c: ExecutionContext

+ 18 - 8
shared/shared/src/main/scala/com/weEat/shared/models/User.scala

@@ -5,18 +5,24 @@ import com.weEat.shared.models.IdentifierHelper._
 
 
 /* Basic User information */
 /* Basic User information */
 case class User(
 case class User(
-  val _id: Identifier,
-  val fname: String,
-  val lname: String,
-  val email: String
+  _id: Identifier,
+  fname: String,
+  lname: String,
+  email: String
 )
 )
 
 
 /* Information to register a new user */
 /* Information to register a new user */
 case class UserRegistration (
 case class UserRegistration (
-  val fname: String,
-  val lname: String,
-  val email: String,
-  val password: String
+  fname: String,
+  lname: String,
+  email: String,
+  password: String
+)
+
+/* Change password request */
+case class PasswordChange(
+  oldPassword: String,
+  newPassword: String,
 )
 )
 
 
 object User {
 object User {
@@ -26,3 +32,7 @@ object User {
 object UserRegistration {
 object UserRegistration {
   implicit val registerFormat = Json.format[UserRegistration]
   implicit val registerFormat = Json.format[UserRegistration]
 }
 }
+
+object PasswordChange {
+  implicit val passwordChangeFormat = Json.format[PasswordChange]
+}

+ 5 - 1
webClient/src/main/scala/com/weEat/Main.scala

@@ -43,7 +43,6 @@ object Main {
       }
       }
     ))
     ))
 
 
-
     windowEvents(_.onLoad).foreach { (_) =>
     windowEvents(_.onLoad).foreach { (_) =>
       document.getElementById("btn-login").asInstanceOf[HTMLAnchorElement]
       document.getElementById("btn-login").asInstanceOf[HTMLAnchorElement]
         .onclick = { (_) => models.LoginVar()
         .onclick = { (_) => models.LoginVar()
@@ -57,6 +56,11 @@ object Main {
         }
         }
       document.getElementById("btn-logout").asInstanceOf[HTMLAnchorElement]
       document.getElementById("btn-logout").asInstanceOf[HTMLAnchorElement]
         .onclick = { (_) => OAuthManager.logout() }
         .onclick = { (_) => OAuthManager.logout() }
+      document.getElementById("btn-chg-pwd").asInstanceOf[HTMLAnchorElement]
+        .onclick = { (_) => models.ChangePasswordVar()
+          .showPrompt()
+          .map(OAuthManager.changePassword(_)(implicitly, implicitly))
+        }
     }(unsafeWindowOwner)
     }(unsafeWindowOwner)
   }
   }
 }
 }

+ 52 - 0
webClient/src/main/scala/com/weEat/models/ChangePasswordVar.scala

@@ -0,0 +1,52 @@
+package com.weEat.models
+
+import com.raquo.laminar.api.L._
+import com.weEat.shared.models.PasswordChange
+import scala.util.{Try,Success,Failure}
+
+case class ChangePasswordVar() extends VarRepresentationOf[PasswordChange] {
+
+  val oldPwd  = Var("")
+  val oldPwd2 = Var("")
+  val newPwd  = Var("")
+
+  val newInstance: Signal[Try[PasswordChange]] =
+    oldPwd.signal.combineWithFn(oldPwd2.signal, newPwd.signal) {
+      // TODO: Why do we need this?  Change bcrypt library.
+      case (_, _, newPwd) if newPwd.length > 71 => Failure(new IllegalStateException("Passwords cannot be more than 71 bytes long."))
+      case (oldPwd, oldPwd2, newPwd) if oldPwd != oldPwd2 => Failure(new IllegalStateException("Passwords do not match."))
+      case (oldPwd, _, newPwd) => Success(PasswordChange(oldPwd, newPwd))
+    }
+
+  protected def renderEditPane(): Node =
+    form(cls := "text-right", idAttr := "view-login",
+      div(cls := "alert alert-danger",
+        display := "none",
+        textAlign := "left"
+      ),
+      div(cls := "input-group",
+        input(idAttr := "oldPwd1",
+          tpe := "password",
+          cls := "form-control",
+          placeholder := "Old Password",
+          onInput.mapToValue --> oldPwd
+        )
+      ),
+      div(cls := "input-group",
+        input(idAttr := "oldPwd2",
+          tpe := "password",
+          cls := "form-control",
+          placeholder := "Old Password (Again)",
+          onInput.mapToValue --> oldPwd2
+        )
+      ),
+      div(cls := "input-group mb-3",
+        input(idAttr := "newPwd",
+          tpe := "password",
+          cls := "form-control",
+          placeholder := "New Password",
+          onInput.mapToValue --> newPwd
+        )
+      )
+    )
+}

+ 2 - 0
webClient/src/main/scala/com/weEat/views/RecipeImporter.scala

@@ -16,6 +16,8 @@ object RecipeImporter extends View[Unit] {
   val navName = Some("Recipe Import")
   val navName = Some("Recipe Import")
   val tag = "recipe-import"
   val tag = "recipe-import"
 
 
+  override val permissions = Set("user")
+
   case class ViewPage() extends P {
   case class ViewPage() extends P {
     val title = "Recipe Import"
     val title = "Recipe Import"
     def jsonValue = JsNull
     def jsonValue = JsNull

+ 7 - 0
webClient/src/main/scala/com/weEat/views/RecipeView.scala

@@ -63,6 +63,13 @@ object RecipeView extends View[String] {
               child.text <-- food.optionMap(_.name).withDefault("loading...")
               child.text <-- food.optionMap(_.name).withDefault("loading...")
             )
             )
           ),
           ),
+          children <-- food.optionFlatMap(_.source).optionMap({ (src) =>
+            div(cls := "row",
+              div(cls := "col-md-12",
+                a(href := src, src)
+              )
+            )
+          }).map(_.toSeq),
           div(cls := "row",
           div(cls := "row",
             div(cls := "col-md-3",
             div(cls := "col-md-3",
               "Servings: ",
               "Servings: ",