Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
DB_URL=postgresql://localhost:5432/acta
DB_URL=jdbc:postgresql://localhost:5432/acta
DB_USER=postgres
DB_PASSWORD=123
FIREBASE_PROJECT_ID=seu-projeto-firebase
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ Ao executar em contêiner, também disponibilize as credenciais de servidor do F
- Público: `GET /api/v1/health`, Swagger UI e OpenAPI.
- Demais rotas: `Authorization: Bearer <FIREBASE_ID_TOKEN>`.

Após o login Firebase, use `POST /auth/ativar` para vincular uma identidade a um usuário ACTA existente, ativo e com e-mail verificado. Consulte `GET /me` para obter o contexto autenticado.
Após o login Firebase, use `GET /me` para consultar o estado do perfil. Uma identidade Firebase válida ainda sem perfil ACTA recebe `estadoPerfil: "CADASTRO_NAO_INICIADO"` e pode iniciar o onboarding; um perfil localizado recebe `estadoPerfil: "PERFIL_CRIADO"` e os dados do usuário. A ausência de perfil não significa sessão inválida. Para vincular um usuário existente, use `POST /auth/ativar` com convite e e-mail verificado.

Na auditoria do cadastro inicial de empresa, `auditoria.log_auditoria.id_usuario = 0` é reservado para identificar operações iniciadas por uma identidade Firebase verificada antes da existência de um usuário ACTA. A API define `app.current_user_id = 0` apenas na transação e no ramo de criação de uma empresa nova; o UID Firebase permanece vinculado ao usuário criado. O valor `0` não representa um usuário ACTA.

> Custom token, refresh token e senha **não** são Firebase ID Tokens e não devem ser usados no cabeçalho `Authorization`.

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
Expand Down
47 changes: 33 additions & 14 deletions src/main/java/br/com/acta/common/client/BrevoClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,30 @@ public class BrevoClient {
private String url;

public void enviarConvite(String email, String nome, OffsetDateTime expiraEm, String token) {
try {
String html = carregarTemplate()
.replace("{{nome}}", HtmlUtils.htmlEscape(nome))
.replace("{{expiraEm}}", expiraEm.format(DateTimeFormatter.ofPattern("dd/MM/yyyy 'às' HH:mm")))
.replace("{{logoUrl}}", HtmlUtils.htmlEscape(url.replaceAll("/+$", "") + "/images/acta-logo.png"))
.replace("{{codigo}}", HtmlUtils.htmlEscape(token));
String html = carregarTemplate("convite-usuario.html")
.replace("{{nome}}", HtmlUtils.htmlEscape(nome))
.replace("{{expiraEm}}", expiraEm.format(DateTimeFormatter.ofPattern("dd/MM/yyyy 'às' HH:mm")))
.replace("{{logoUrl}}", HtmlUtils.htmlEscape(url.replaceAll("/+$", "") + "/images/acta-logo.png"))
.replace("{{codigo}}", HtmlUtils.htmlEscape(token));

enviarEmail(email, "Convite para acessar o ACTA", html);
}

Map<String, Object> mensagem = Map.of(
"sender", Map.of("name", "ACTA", "email", senderEmail),
"to", List.of(Map.of("email", email)),
"subject", "Convite para acessar o ACTA",
"htmlContent", html
);
public void enviarConviteAdmin(String email, String nome, String empresa, OffsetDateTime expiraEm, String token) {
String html = carregarTemplate("convite-admin.html")
.replace("{{nome}}", HtmlUtils.htmlEscape(nome))
.replace("{{empresa}}", HtmlUtils.htmlEscape(empresa))
.replace("{{expiraEm}}", expiraEm.format(DateTimeFormatter.ofPattern("dd/MM/yyyy 'às' HH:mm")))
.replace("{{logoUrl}}", HtmlUtils.htmlEscape(url.replaceAll("/+$", "") + "/images/acta-logo.png"))
.replace("{{codigo}}", HtmlUtils.htmlEscape(token));

enviarEmail(email, "Convite para administrar uma empresa no ACTA", html);
}

private void enviarEmail(String email, String assunto, String html) {
Map<String, Object> mensagem = montarMensagem(email, assunto, html);

try {
REST_CLIENT.post()
.uri("https://api.brevo.com/v3/smtp/email").contentType(MediaType.APPLICATION_JSON)
.header("api-key", apiKey)
Expand All @@ -54,8 +64,17 @@ public void enviarConvite(String email, String nome, OffsetDateTime expiraEm, St
}
}

private String carregarTemplate() {
try (InputStream input = new ClassPathResource("templates/email/convite-usuario.html").getInputStream()) {
private Map<String, Object> montarMensagem(String email, String assunto, String html) {
return Map.of(
"sender", Map.of("name", "ACTA", "email", senderEmail),
"to", List.of(Map.of("email", email)),
"subject", assunto,
"htmlContent", html
);
}

private String carregarTemplate(String template) {
try (InputStream input = new ClassPathResource("templates/email/" + template).getInputStream()) {
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException ioe) {
throw new BusinessRuleException("Não foi possível carregar o e-mail de convite");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
@RequiredArgsConstructor
public class FirebaseAuthFilter extends OncePerRequestFilter {
private static final String AUTH = "/api/v1/auth/ativar";
private static final String ONBOARDING = "/api/v1/onboarding/inicio";
private static final String ONBOARDING_EMPRESA = "/api/v1/onboarding/gestor";
private static final String ME = "/api/v1/me";
private final FirebaseUtils utils;
private final ObjectMapper mapper;

Expand All @@ -50,7 +53,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse

FirebaseToken idToken = utils.verificarIdToken(token);

if (ehRotaAutenticacao(request)) {
if (ehRotaIdentidadeFirebase(request)) {
Principal principal = new FirebaseIdentity(idToken.getUid(), idToken.getEmail(), idToken.isEmailVerified());
utils.autenticar(request, principal, List.of(new SimpleGrantedAuthority("ROLE_FIREBASE")));
} else {
Expand All @@ -59,7 +62,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
}

filterChain.doFilter(request, response);
} catch (BadCredentialsException bce){
} catch (BadCredentialsException bce) {
SecurityContextHolder.clearContext();
responderErro(response, HttpStatus.UNAUTHORIZED, "O ID Token do Firebase não existe ou está inválido");
} catch (AccessDeniedException ade) {
Expand All @@ -75,11 +78,16 @@ public String getName() {
}
}

private boolean ehRotaAutenticacao(HttpServletRequest request) {
return request.getServletPath().equals(AUTH) && "POST".equalsIgnoreCase(request.getMethod());
private boolean ehRotaIdentidadeFirebase(HttpServletRequest request) {
String path = request.getServletPath();
String method = request.getMethod();
return (path.equals(AUTH) && "POST".equalsIgnoreCase(method))
|| (path.equals(ONBOARDING) && "POST".equalsIgnoreCase(method))
|| (path.equals(ONBOARDING_EMPRESA) && "POST".equalsIgnoreCase(method))
|| (path.equals(ME) && "GET".equalsIgnoreCase(method));
}

private void responderErro(HttpServletResponse resp, HttpStatus status, String mensagem) throws IOException {
private void responderErro(HttpServletResponse resp, HttpStatus status, String mensagem) throws IOException {
resp.setStatus(status.value());
resp.setContentType(MediaType.APPLICATION_JSON_VALUE);
resp.setCharacterEncoding(StandardCharsets.UTF_8.name());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, FirebaseAuthFi

// filtro ainda nao procura usuario no banco, so valida a identidade firebase
.requestMatchers(HttpMethod.POST, "/api/v1/auth/ativar").hasAuthority("ROLE_FIREBASE")
.requestMatchers(HttpMethod.POST, "/api/v1/onboarding/inicio").hasAuthority("ROLE_FIREBASE")
.requestMatchers(HttpMethod.POST, "/api/v1/onboarding/gestor").hasAuthority("ROLE_FIREBASE")
.requestMatchers(HttpMethod.GET, "/api/v1/me").hasAuthority("ROLE_FIREBASE")

// o restante so segue depois que o filtro montou o usuario autenticado
.anyRequest().authenticated()
Expand All @@ -111,4 +114,4 @@ private void erro(ObjectMapper mapper, HttpServletResponse resp, HttpStatus stat

mapper.writeValue(resp.getOutputStream(), new ErroResponse(List.of(msg), status.value()));
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
package br.com.acta.common.config.swagger.examples;

public final class SwaggerOpenapiDescriptions {
public static final String ONBOARDING_CONTROLLER = """
Endpoints responsáveis pelo cadastro de administradores e empresas.

A API permite:

- Verificar se a empresa já existe;
- Solicitar acesso como administrador de uma empresa existente;
- Criar uma empresa e seu primeiro administrador.
""";

public static final String AUTH_CONTROLLER = """
Endpoints responsáveis pela autenticação e ativação de usuários.

Expand Down Expand Up @@ -246,4 +256,4 @@ public final class SwaggerOpenapiDescriptions {

(* Algumas operações dependem do status atual do ciclo. Ciclos encerrados, concluídos ou cancelados podem ter sua alteração bloqueada pelas regras de negócio)
""";
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package br.com.acta.common.config.swagger.openapi;

import br.com.acta.common.config.firebase.FirebaseAuthFilter.FirebaseIdentity;
import br.com.acta.common.config.firebase.UsuarioAutenticado;
import br.com.acta.common.config.swagger.annotation.ApiAuthenticationResponses;
import br.com.acta.common.config.swagger.annotation.ApiBusinessRuleResponse;
import br.com.acta.common.config.swagger.annotation.ApiConflictResponse;
Expand All @@ -25,9 +24,9 @@
public interface AuthOpenapi {

@Operation(summary = "Consulta o usuário autenticado")
@ApiResponse(responseCode = "200", description = "Usuário autenticado", content = @Content(schema = @Schema(implementation = MeResponseDTO.class)))
@ApiResponse(responseCode = "200", description = "Identidade Firebase válida; estadoPerfil informa PERFIL_CRIADO ou CADASTRO_NAO_INICIADO", content = @Content(schema = @Schema(implementation = MeResponseDTO.class)))
@ApiAuthenticationResponses
ResponseEntity<MeResponseDTO> me(@Parameter(hidden = true) UsuarioAutenticado usuario);
ResponseEntity<MeResponseDTO> me(@Parameter(hidden = true) FirebaseIdentity identity);

@Operation(summary = "Ativa o usuário autenticado com código de convite")
@ApiResponse(responseCode = "200", description = "Usuário ativado", content = @Content(schema = @Schema(implementation = MeResponseDTO.class)))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package br.com.acta.common.config.swagger.openapi;

import br.com.acta.common.config.swagger.annotation.ApiAuthenticationResponses;
import br.com.acta.common.config.swagger.annotation.ApiResourceResponses;
import br.com.acta.common.config.swagger.annotation.ApiBadRequestResponse;
import br.com.acta.common.config.swagger.annotation.ApiConflictResponse;
import br.com.acta.common.config.swagger.annotation.ApiNotFoundResponse;
import br.com.acta.common.config.swagger.annotation.ApiResourceResponses;
import br.com.acta.common.config.swagger.annotation.ApiUnsupportedMediaTypeResponse;
import br.com.acta.common.config.swagger.examples.SwaggerOpenapiDescriptions;
import br.com.acta.common.config.swagger.examples.SwaggerParameterDescriptions;
Expand All @@ -16,17 +16,16 @@
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import org.springframework.http.ResponseEntity;

import java.util.List;
import java.util.Map;

@Tag(name = "Empresas", description = SwaggerOpenapiDescriptions.EMPRESA_CONTROLLER)
public interface EmpresaOpenapi {

@Operation(summary = "Lista as empresas")
@ApiResponse(responseCode = "200", description = "Empresas encontradas", content = @Content(array = @ArraySchema(schema = @Schema(implementation = EmpresaResponseDTO.class))))
@ApiAuthenticationResponses
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package br.com.acta.common.config.swagger.openapi;

import br.com.acta.common.config.firebase.FirebaseAuthFilter.FirebaseIdentity;
import br.com.acta.common.config.swagger.annotation.ApiAuthenticationResponses;
import br.com.acta.common.config.swagger.annotation.ApiBadRequestResponse;
import br.com.acta.common.config.swagger.annotation.ApiConflictResponse;
import br.com.acta.dto.core.empresa.OnboardingInicioRequestDTO;
import br.com.acta.dto.core.empresa.OnboardingRequestDTO;
import br.com.acta.dto.core.empresa.OnboardingResponseDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;

@Tag(name = "Onboarding", description = "Fluxo de entrada de administradores e criação de empresas")
public interface OnboardingOpenapi {
@Operation(summary = "Verifica a empresa e inicia o onboarding do gestor")
@ApiResponse(responseCode = "200", description = "Empresa identificada ou próxima etapa liberada", content = @Content(schema = @Schema(implementation = OnboardingResponseDTO.class)))
@ApiAuthenticationResponses
@ApiBadRequestResponse
@ApiConflictResponse
ResponseEntity<OnboardingResponseDTO> iniciar(@Parameter(hidden = true) FirebaseIdentity identity, @RequestBody(description = "Dados do gestor e CNPJ da empresa", required = true, content = @Content(schema = @Schema(implementation = OnboardingInicioRequestDTO.class))) OnboardingInicioRequestDTO dto);

@Operation(summary = "Cadastra a empresa e seu primeiro gestor")
@ApiResponse(responseCode = "201", description = "Empresa criada e primeiro gestor ativado", content = @Content(schema = @Schema(implementation = OnboardingResponseDTO.class)))
@ApiAuthenticationResponses
@ApiBadRequestResponse
@ApiConflictResponse
ResponseEntity<OnboardingResponseDTO> cadastrarEmpresa(@Parameter(hidden = true) FirebaseIdentity identity, @RequestBody(description = "Dados do gestor, empresa e endereço principal", required = true, content = @Content(schema = @Schema(implementation = OnboardingRequestDTO.class))) OnboardingRequestDTO dto);

}
15 changes: 4 additions & 11 deletions src/main/java/br/com/acta/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -1,40 +1,33 @@
package br.com.acta.controller;

import br.com.acta.common.config.firebase.FirebaseAuthFilter.FirebaseIdentity;
import br.com.acta.common.config.firebase.UsuarioAutenticado;
import br.com.acta.common.config.swagger.openapi.AuthOpenapi;
import br.com.acta.dto.auth.AtivarRequestDTO;
import br.com.acta.dto.auth.AuthMapper;
import br.com.acta.dto.auth.ConviteRequestDTO;
import br.com.acta.dto.auth.MeResponseDTO;
import br.com.acta.dto.core.colaborador.ColaboradorResponseDTO;
import br.com.acta.service.ConviteService;
import br.com.acta.service.AuthService;
import br.com.acta.service.ConviteService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(value = "/api/v1", produces = MediaType.APPLICATION_JSON_VALUE)
@RequiredArgsConstructor
public class AuthController implements AuthOpenapi {
private final ConviteService conviteService;
private final AuthMapper mapper;
private final AuthService authService;

@GetMapping("/me")
@Override
public ResponseEntity<MeResponseDTO> me(@AuthenticationPrincipal UsuarioAutenticado usuario) {
return ResponseEntity.ok(mapper.toMeResponse(usuario));
public ResponseEntity<MeResponseDTO> me(@AuthenticationPrincipal FirebaseIdentity identity) {
return ResponseEntity.ok(authService.buscarParaMe(identity));
}

@PostMapping("/auth/ativar")
Expand Down
33 changes: 33 additions & 0 deletions src/main/java/br/com/acta/controller/OnboardingController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package br.com.acta.controller;

import br.com.acta.common.config.firebase.FirebaseAuthFilter.FirebaseIdentity;
import br.com.acta.common.config.swagger.openapi.OnboardingOpenapi;
import br.com.acta.dto.core.empresa.OnboardingInicioRequestDTO;
import br.com.acta.dto.core.empresa.OnboardingRequestDTO;
import br.com.acta.dto.core.empresa.OnboardingResponseDTO;
import br.com.acta.service.OnboardingService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(value = "/api/v1/onboarding", produces = MediaType.APPLICATION_JSON_VALUE)
@RequiredArgsConstructor
public class OnboardingController implements OnboardingOpenapi {
private final OnboardingService service;

@PostMapping("/inicio")
@Override
public ResponseEntity<OnboardingResponseDTO> iniciar(@AuthenticationPrincipal FirebaseIdentity identity, @RequestBody @Valid OnboardingInicioRequestDTO dto) {
return ResponseEntity.ok(service.iniciar(identity, dto));
}

@PostMapping("/gestor")
@Override
public ResponseEntity<OnboardingResponseDTO> cadastrarEmpresa(@AuthenticationPrincipal FirebaseIdentity identity, @RequestBody @Valid OnboardingRequestDTO dto) {
return ResponseEntity.status(201).body(service.cadastrarEmpresa(identity, dto));
}
}
3 changes: 2 additions & 1 deletion src/main/java/br/com/acta/dto/auth/AuthMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

@Mapper(componentModel = "spring")
public interface AuthMapper {
@Mapping(target = "estadoPerfil", constant = "PERFIL_CRIADO")
MeResponseDTO toMeResponse(UsuarioAutenticado usuario);

@Mapping(source = "id", target = "idUsuario")
Expand All @@ -16,4 +17,4 @@ public interface AuthMapper {
@Mapping(source = "empresa.nome", target = "nomeEmpresa")
@Mapping(source = "colaborador.permissaoGestor", target = "permissaoGestor")
UsuarioAutenticado toUsuarioAutenticado(Usuario usuario);
}
}
6 changes: 4 additions & 2 deletions src/main/java/br/com/acta/dto/auth/MeResponseDTO.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package br.com.acta.dto.auth;

import br.com.acta.entity.enums.EstadoPerfil;
import br.com.acta.entity.enums.StatusGeral;
import br.com.acta.entity.enums.TipoUsuario;

Expand All @@ -13,5 +14,6 @@ public record MeResponseDTO(
String nomeEmpresa,
TipoUsuario tipo,
boolean permissaoGestor,
StatusGeral status
) { }
StatusGeral status,
EstadoPerfil estadoPerfil
) { }
Loading
Loading