Merge device-client into messages-client

Issue gh-1189
This commit is contained in:
Joe Grandja
2023-04-28 16:53:10 -04:00
parent 9f1a6e386f
commit e8c0837fef
20 changed files with 86 additions and 345 deletions

View File

@@ -1,28 +0,0 @@
plugins {
id "org.springframework.boot" version "3.0.0"
id "io.spring.dependency-management" version "1.0.11.RELEASE"
id "java"
}
group = project.rootProject.group
version = project.rootProject.version
sourceCompatibility = "17"
repositories {
mavenCentral()
maven { url "https://repo.spring.io/milestone" }
}
dependencies {
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.boot:spring-boot-starter-thymeleaf"
implementation "org.springframework.boot:spring-boot-starter-security"
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
implementation "org.springframework:spring-webflux"
implementation "org.webjars:webjars-locator-core"
implementation "org.webjars:bootstrap:3.4.1"
implementation "org.webjars:jquery:3.4.1"
testImplementation "org.springframework.boot:spring-boot-starter-test"
testImplementation "org.springframework.security:spring-security-test"
}

View File

@@ -1,32 +0,0 @@
/*
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* @author Steve Riesenberg
* @since 1.1
*/
@SpringBootApplication
public class DeviceClientApplication {
public static void main(String[] args) {
SpringApplication.run(DeviceClientApplication.class, args);
}
}

View File

@@ -1,56 +0,0 @@
/*
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
/**
* @author Steve Riesenberg
* @since 1.1
*/
@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
public class SecurityConfig {
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().requestMatchers("/webjars/**", "/assets/**");
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/", "/authorize").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling((exceptions) -> exceptions
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/"))
)
.oauth2Client(Customizer.withDefaults());
// @formatter:on
return http.build();
}
}

View File

@@ -1,71 +0,0 @@
/*
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.config;
import sample.web.authentication.DeviceCodeOAuth2AuthorizedClientProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
/**
* @author Steve Riesenberg
* @since 1.1
*/
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
// @formatter:off
return WebClient.builder()
.apply(oauth2Client.oauth2Configuration())
.build();
// @formatter:on
}
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.provider(new DeviceCodeOAuth2AuthorizedClientProvider())
.authorizationCode()
.refreshToken()
.build();
DefaultOAuth2AuthorizedClientManager authorizedClientManager =
new DefaultOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientRepository);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
// Set a contextAttributesMapper to obtain device_code from the request
authorizedClientManager.setContextAttributesMapper(DeviceCodeOAuth2AuthorizedClientProvider
.deviceCodeContextAttributesMapper());
return authorizedClientManager;
}
}

View File

@@ -1,52 +0,0 @@
/*
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.web;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
/**
* @author Steve Riesenberg
* @since 1.1
*/
@ControllerAdvice
public class DeviceControllerAdvice {
private static final Set<String> DEVICE_GRANT_ERRORS = new HashSet<>(Arrays.asList(
"authorization_pending",
"slow_down",
"access_denied",
"expired_token"
));
@ExceptionHandler(OAuth2AuthorizationException.class)
public ResponseEntity<OAuth2Error> handleError(OAuth2AuthorizationException ex) {
String errorCode = ex.getError().getErrorCode();
if (DEVICE_GRANT_ERRORS.contains(errorCode)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ex.getError());
}
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ex.getError());
}
}

View File

@@ -1,29 +0,0 @@
server:
port: 8080
logging:
level:
root: INFO
org.springframework.security: trace
spring:
thymeleaf:
cache: false
security:
oauth2:
client:
registration:
messaging-client-device-grant:
provider: spring
client-id: device-messaging-client
client-authentication-method: none
authorization-grant-type: urn:ietf:params:oauth:grant-type:device_code
scope: message.read,message.write
client-name: messaging-client-device-grant
provider:
spring:
issuer-uri: http://localhost:9000
authorization-uri: ${spring.security.oauth2.client.provider.spring.issuer-uri}/oauth2/device_authorization
messages:
base-uri: http://127.0.0.1:8090/messages

View File

@@ -1,35 +0,0 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Device Grant Example</title>
<link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
<link rel="stylesheet" href="/assets/css/style.css" th:href="@{/assets/css/style.css}" />
</head>
<body>
<div class="jumbotron">
<div class="container">
<div class="row">
<div class="col-md-8">
<h2>Success!</h2>
<p>This device has been activated.</p>
</div>
<div class="col-md-4">
<img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
</div>
<div class="col-md-12" th:if="${messages}">
<h4>Messages:</h4>
<table class="table table-condensed">
<tbody>
<tr class="row" th:each="message : ${messages}">
<td th:text="${message}">message</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -15,7 +15,6 @@
*/
package sample.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -31,23 +30,22 @@ import static org.springframework.security.config.Customizer.withDefaults;
/**
* @author Joe Grandja
* @author Dmitriy Dubson
* @author Steve Riesenberg
* @since 0.0.1
*/
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
@Autowired
private ClientRegistrationRepository clientRegistrationRepository;
@Bean
WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().requestMatchers("/webjars/**");
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().requestMatchers("/webjars/**", "/assets/**");
}
// @formatter:off
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
public SecurityFilterChain securityFilterChain(HttpSecurity http,
ClientRegistrationRepository clientRegistrationRepository) throws Exception {
http
.authorizeHttpRequests(authorize ->
authorize
@@ -58,14 +56,15 @@ public class SecurityConfig {
oauth2Login.loginPage("/oauth2/authorization/messaging-client-oidc"))
.oauth2Client(withDefaults())
.logout(logout ->
logout.logoutSuccessHandler(oidcLogoutSuccessHandler()));
logout.logoutSuccessHandler(oidcLogoutSuccessHandler(clientRegistrationRepository)));
return http.build();
}
// @formatter:on
private LogoutSuccessHandler oidcLogoutSuccessHandler() {
private LogoutSuccessHandler oidcLogoutSuccessHandler(
ClientRegistrationRepository clientRegistrationRepository) {
OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler =
new OidcClientInitiatedLogoutSuccessHandler(this.clientRegistrationRepository);
new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
// Set the location that the End-User's User Agent will be redirected to
// after the logout has been performed at the Provider

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020 the original author or authors.
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,6 +15,8 @@
*/
package sample.config;
import sample.web.authentication.DeviceCodeOAuth2AuthorizedClientProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
@@ -28,35 +30,47 @@ import org.springframework.web.reactive.function.client.WebClient;
/**
* @author Joe Grandja
* @author Steve Riesenberg
* @since 0.0.1
*/
@Configuration
public class WebClientConfig {
@Bean
WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
// @formatter:off
return WebClient.builder()
.apply(oauth2Client.oauth2Configuration())
.build();
// @formatter:on
}
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
// @formatter:off
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken()
.clientCredentials()
.provider(new DeviceCodeOAuth2AuthorizedClientProvider())
.build();
// @formatter:on
DefaultOAuth2AuthorizedClientManager authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientRepository);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
// Set a contextAttributesMapper to obtain device_code from the request
authorizedClientManager.setContextAttributesMapper(DeviceCodeOAuth2AuthorizedClientProvider
.deviceCodeContextAttributesMapper());
return authorizedClientManager;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2022 the original author or authors.
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -93,4 +93,10 @@ public class AuthorizationController {
return "index";
}
@GetMapping(value = "/authorize", params = "grant_type=device_code")
public String deviceCodeGrant() {
return "device-activate";
}
}

View File

@@ -16,8 +16,11 @@
package sample.web;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -37,7 +40,9 @@ import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2Aut
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository;
@@ -46,6 +51,7 @@ import org.springframework.ui.Model;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -61,6 +67,13 @@ import static org.springframework.security.oauth2.client.web.reactive.function.c
@Controller
public class DeviceController {
private static final Set<String> DEVICE_GRANT_ERRORS = new HashSet<>(Arrays.asList(
"authorization_pending",
"slow_down",
"access_denied",
"expired_token"
));
private static final ParameterizedTypeReference<Map<String, Object>> TYPE_REFERENCE =
new ParameterizedTypeReference<>() {};
@@ -84,17 +97,12 @@ public class DeviceController {
this.messagesBaseUri = messagesBaseUri;
}
@GetMapping("/")
public String index() {
return "index";
}
@GetMapping("/authorize")
@GetMapping("/device_authorize")
public String authorize(Model model, HttpServletRequest request, HttpServletResponse response) {
// @formatter:off
ClientRegistration clientRegistration =
this.clientRegistrationRepository.findByRegistrationId(
"messaging-client-device-grant");
"messaging-client-device-code");
// @formatter:on
MultiValueMap<String, String> requestParameters = new LinkedMultiValueMap<>();
@@ -102,10 +110,12 @@ public class DeviceController {
requestParameters.add(OAuth2ParameterNames.SCOPE, StringUtils.collectionToDelimitedString(
clientRegistration.getScopes(), " "));
String deviceAuthorizationUri = (String) clientRegistration.getProviderDetails().getConfigurationMetadata().get("device_authorization_endpoint");
// @formatter:off
Map<String, Object> responseParameters =
this.webClient.post()
.uri(clientRegistration.getProviderDetails().getAuthorizationUri())
.uri(deviceAuthorizationUri)
.headers(headers -> {
/*
* This sample demonstrates the use of a public client that does not
@@ -146,15 +156,15 @@ public class DeviceController {
model.addAttribute("verificationUriComplete", responseParameters.get(
OAuth2ParameterNames.VERIFICATION_URI_COMPLETE));
return "authorize";
return "device-authorize";
}
/**
* @see DeviceControllerAdvice
* @see #handleError(OAuth2AuthorizationException)
*/
@PostMapping("/authorize")
@PostMapping("/device_authorize")
public ResponseEntity<Void> poll(@RequestParam(OAuth2ParameterNames.DEVICE_CODE) String deviceCode,
@RegisteredOAuth2AuthorizedClient("messaging-client-device-grant")
@RegisteredOAuth2AuthorizedClient("messaging-client-device-code")
OAuth2AuthorizedClient authorizedClient) {
/*
@@ -175,9 +185,18 @@ public class DeviceController {
return ResponseEntity.status(HttpStatus.OK).build();
}
@GetMapping("/authorized")
@ExceptionHandler(OAuth2AuthorizationException.class)
public ResponseEntity<OAuth2Error> handleError(OAuth2AuthorizationException ex) {
String errorCode = ex.getError().getErrorCode();
if (DEVICE_GRANT_ERRORS.contains(errorCode)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ex.getError());
}
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ex.getError());
}
@GetMapping("/device_authorized")
public String authorized(Model model,
@RegisteredOAuth2AuthorizedClient("messaging-client-device-grant")
@RegisteredOAuth2AuthorizedClient("messaging-client-device-code")
OAuth2AuthorizedClient authorizedClient) {
String[] messages = this.webClient.get()
@@ -188,7 +207,7 @@ public class DeviceController {
.block();
model.addAttribute("messages", messages);
return "authorized";
return "index";
}
private void saveSecurityContext(OAuth2DeviceCode deviceCode, HttpServletRequest request,

View File

@@ -50,9 +50,6 @@ public final class DeviceCodeOAuth2AuthorizedClientProvider implements OAuth2Aut
private Clock clock = Clock.systemUTC();
public DeviceCodeOAuth2AuthorizedClientProvider() {
}
public void setAccessTokenResponseClient(OAuth2AccessTokenResponseClient<OAuth2DeviceGrantRequest> accessTokenResponseClient) {
this.accessTokenResponseClient = accessTokenResponseClient;
}

View File

@@ -35,7 +35,7 @@ public final class OAuth2DeviceGrantRequest extends AbstractOAuth2AuthorizationG
}
public String getDeviceCode() {
return deviceCode;
return this.deviceCode;
}
}

View File

@@ -7,7 +7,6 @@ logging:
org.springframework.web: INFO
org.springframework.security: INFO
org.springframework.security.oauth2: INFO
# org.springframework.boot.autoconfigure: DEBUG
spring:
thymeleaf:
@@ -39,6 +38,13 @@ spring:
authorization-grant-type: client_credentials
scope: message.read,message.write
client-name: messaging-client-client-credentials
messaging-client-device-code:
provider: spring
client-id: device-messaging-client
client-authentication-method: none
authorization-grant-type: urn:ietf:params:oauth:grant-type:device_code
scope: message.read,message.write
client-name: messaging-client-device-code
provider:
spring:
issuer-uri: http://localhost:9000

View File

@@ -13,8 +13,8 @@
<div class="row">
<div class="col-md-8">
<h2>Activation Required</h2>
<p>You must activate this device. Please log in to continue.</p>
<a th:href="@{/authorize}" class="btn btn-primary" role="button">Log In</a>
<p>You must activate this device.</p>
<a th:href="@{/device_authorize}" class="btn btn-primary" role="button">Activate</a>
</div>
<div class="col-md-4">
<img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
@@ -23,4 +23,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -17,7 +17,7 @@
<p class="gap">Activation Code</p>
<div class="well">
<span class="code" th:text="${userCode}"></span>
<form id="authorize-form" th:action="@{/authorize}" method="post">
<form id="authorize-form" th:action="@{/device_authorize}" method="post">
<input type="hidden" id="device_code" name="device_code" th:value="${deviceCode}" />
</form>
</div>
@@ -35,7 +35,7 @@
let csrfToken = $('[name=_csrf]').val();
if (deviceCode) {
$.ajax({
url: '/authorize',
url: '/device_authorize',
method: 'POST',
data: {
device_code: deviceCode,
@@ -58,7 +58,7 @@
clear();
location.href = '/';
}
}).done(() => window.location.href = '/authorized');
}).done(() => window.location.href = '/device_authorized');
}
}
@@ -84,4 +84,4 @@
window.addEventListener('load', schedule);
</script>
</body>
</html>
</html>

View File

@@ -30,11 +30,14 @@
</div>
<ul class="list-group">
<li class="list-group-item">
<a href="/authorize?grant_type=authorization_code" th:href="@{/authorize?grant_type=authorization_code}"><span style="font-size:medium">Authorization Code</span>&nbsp;&nbsp;<small class="text-muted">(Login to Spring Authorization Server using: user1/password)</small></a>
<a href="/authorize?grant_type=authorization_code" th:href="@{/authorize?grant_type=authorization_code}"><span style="font-size:medium">Authorization Code</span></a>
</li>
<li class="list-group-item">
<a href="/authorize?grant_type=client_credentials" th:href="@{/authorize?grant_type=client_credentials}"><span style="font-size:medium">Client Credentials</span></a>
</li>
<li class="list-group-item">
<a href="/authorize?grant_type=device_code" th:href="@{/authorize?grant_type=device_code}"><span style="font-size:medium">Device Code</span></a>
</li>
</ul>
<div th:if="${messages}" class="panel-footer">
<h4>Messages:</h4>